Add live RZ Points balance widget to question and shell headers
Wires the recently-merged question-completion-credits backend (GET /api/credits) into the client for the first time: a new CreditsService autoload fetches/caches the balance, and the embedded credits field on first-answer responses updates it instantly with no extra round trip. A shared CreditsBadge widget (star icon, balance, decorative chevron per mockup) surfaces it in both the question screen toolbar and the main shell header. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -40,6 +40,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
|
|||||||
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||||
|
CreditsService="*res://scripts/autoload/credits_service.gd"
|
||||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
signal credits_changed
|
||||||
|
signal credits_failed(message: String)
|
||||||
|
|
||||||
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||||
|
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||||
|
|
||||||
|
var credits: int = 0
|
||||||
|
var loaded := false
|
||||||
|
var loading := false
|
||||||
|
var request: HTTPRequest
|
||||||
|
var request_token := ""
|
||||||
|
var api_url := DEFAULT_URL
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
api_url = EndpointConfig.resolve(
|
||||||
|
str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)),
|
||||||
|
"/api/credits")
|
||||||
|
request = HTTPRequest.new()
|
||||||
|
request.timeout = 20.0
|
||||||
|
request.request_completed.connect(_on_completed)
|
||||||
|
add_child(request)
|
||||||
|
AuthState.auth_changed.connect(_on_auth_changed)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
if loading or not AuthState.can_send_authenticated_request():
|
||||||
|
return
|
||||||
|
loading = true
|
||||||
|
request_token = AuthState.access_token
|
||||||
|
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
|
||||||
|
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
|
||||||
|
if error != OK:
|
||||||
|
loading = false
|
||||||
|
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
|
||||||
|
|
||||||
|
|
||||||
|
func apply_status(parsed: Dictionary) -> void:
|
||||||
|
credits = int(parsed.get("credits", credits))
|
||||||
|
loaded = true
|
||||||
|
credits_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
loading = false
|
||||||
|
if response_code == 401:
|
||||||
|
AuthState.handle_unauthorized(request_token)
|
||||||
|
return
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
|
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
|
||||||
|
return
|
||||||
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
credits_failed.emit("The credits service returned an invalid response.")
|
||||||
|
return
|
||||||
|
apply_status(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_auth_changed() -> void:
|
||||||
|
if not AuthState.is_logged_in:
|
||||||
|
credits = 0
|
||||||
|
loaded = false
|
||||||
|
loading = false
|
||||||
|
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
request.cancel_request()
|
||||||
@@ -507,6 +507,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
|||||||
var rating: Dictionary = parsed.get("rating", {})
|
var rating: Dictionary = parsed.get("rating", {})
|
||||||
_update_question_elo(question_id, question_elo)
|
_update_question_elo(question_id, question_elo)
|
||||||
_apply_rating_change(rating)
|
_apply_rating_change(rating)
|
||||||
|
var credits_status: Variant = parsed.get("credits", null)
|
||||||
|
if typeof(credits_status) == TYPE_DICTIONARY:
|
||||||
|
CreditsService.apply_status(credits_status)
|
||||||
choice_percentages_updated.emit(question_id, percentages)
|
choice_percentages_updated.emit(question_id, percentages)
|
||||||
question_rating_updated.emit(question_id, question_elo, rating)
|
question_rating_updated.emit(question_id, question_elo, rating)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
extends PanelContainer
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||||
|
const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg")
|
||||||
|
|
||||||
|
var icon_panel: PanelContainer
|
||||||
|
var icon_texture: TextureRect
|
||||||
|
var value_label: Label
|
||||||
|
var caption_label: Label
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
custom_minimum_size = Vector2(150, 44)
|
||||||
|
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
_build_ui()
|
||||||
|
refresh_theme_controls()
|
||||||
|
_on_credits_changed()
|
||||||
|
CreditsService.credits_changed.connect(_on_credits_changed)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 10)
|
||||||
|
margin.add_theme_constant_override("margin_right", 10)
|
||||||
|
margin.add_theme_constant_override("margin_top", 6)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 6)
|
||||||
|
add_child(margin)
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(row)
|
||||||
|
|
||||||
|
icon_panel = PanelContainer.new()
|
||||||
|
icon_panel.custom_minimum_size = Vector2(28, 28)
|
||||||
|
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
icon_texture = TextureRect.new()
|
||||||
|
icon_texture.texture = STAR_ICON
|
||||||
|
icon_texture.custom_minimum_size = Vector2(16, 16)
|
||||||
|
icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
icon_panel.add_child(icon_texture)
|
||||||
|
row.add_child(icon_panel)
|
||||||
|
|
||||||
|
var copy := VBoxContainer.new()
|
||||||
|
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
copy.add_theme_constant_override("separation", 0)
|
||||||
|
value_label = Label.new()
|
||||||
|
value_label.add_theme_font_override("font", Brand.font)
|
||||||
|
value_label.add_theme_font_size_override("font_size", 15)
|
||||||
|
copy.add_child(value_label)
|
||||||
|
caption_label = Label.new()
|
||||||
|
caption_label.text = "RZ Points"
|
||||||
|
caption_label.add_theme_font_override("font", Brand.font)
|
||||||
|
caption_label.add_theme_font_size_override("font_size", 10)
|
||||||
|
copy.add_child(caption_label)
|
||||||
|
row.add_child(copy)
|
||||||
|
|
||||||
|
var chevron := TextureRect.new()
|
||||||
|
chevron.texture = CHEVRON_ICON
|
||||||
|
chevron.custom_minimum_size = Vector2(14, 14)
|
||||||
|
chevron.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
chevron.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
chevron.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
chevron.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
row.add_child(chevron)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_theme_controls() -> void:
|
||||||
|
var dark := UIThemeService.is_dark_mode()
|
||||||
|
add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.AMBER))
|
||||||
|
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 14))
|
||||||
|
icon_texture.modulate = Brand.AMBER
|
||||||
|
value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||||
|
caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_credits_changed() -> void:
|
||||||
|
value_label.text = _format_thousands(CreditsService.credits) if CreditsService.loaded else "--"
|
||||||
|
|
||||||
|
|
||||||
|
func _format_thousands(value: int) -> String:
|
||||||
|
var digits := str(absi(value))
|
||||||
|
var grouped := ""
|
||||||
|
var count := 0
|
||||||
|
for index in range(digits.length() - 1, -1, -1):
|
||||||
|
grouped = digits[index] + grouped
|
||||||
|
count += 1
|
||||||
|
if count % 3 == 0 and index != 0:
|
||||||
|
grouped = "," + grouped
|
||||||
|
return ("-" if value < 0 else "") + grouped
|
||||||
@@ -3,6 +3,7 @@ extends Control
|
|||||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
||||||
|
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||||
|
|
||||||
var screens := {}
|
var screens := {}
|
||||||
var _account_profile_synced := false
|
var _account_profile_synced := false
|
||||||
@@ -29,6 +30,7 @@ var drawer_open := false
|
|||||||
var menu_button: Button
|
var menu_button: Button
|
||||||
var account_button: Button
|
var account_button: Button
|
||||||
var account_chevron_button: Button
|
var account_chevron_button: Button
|
||||||
|
var credits_badge: PanelContainer
|
||||||
var account_menu: PanelContainer
|
var account_menu: PanelContainer
|
||||||
var nav_buttons: Dictionary = {}
|
var nav_buttons: Dictionary = {}
|
||||||
var current_nav_key := "home"
|
var current_nav_key := "home"
|
||||||
@@ -204,6 +206,9 @@ func _build_ui() -> void:
|
|||||||
_style_account_chevron(account_chevron_button)
|
_style_account_chevron(account_chevron_button)
|
||||||
header.add_child(account_chevron_button)
|
header.add_child(account_chevron_button)
|
||||||
|
|
||||||
|
credits_badge = CreditsBadge.new()
|
||||||
|
header.add_child(credits_badge)
|
||||||
|
|
||||||
var screens_container := MarginContainer.new()
|
var screens_container := MarginContainer.new()
|
||||||
screens_margin = screens_container
|
screens_margin = screens_container
|
||||||
screens_host = screens_container
|
screens_host = screens_container
|
||||||
@@ -769,6 +774,7 @@ func _on_auth_changed() -> void:
|
|||||||
if not _account_profile_synced:
|
if not _account_profile_synced:
|
||||||
_account_profile_synced = true
|
_account_profile_synced = true
|
||||||
AccountProfileService.refresh()
|
AccountProfileService.refresh()
|
||||||
|
CreditsService.refresh()
|
||||||
else:
|
else:
|
||||||
_account_profile_synced = false
|
_account_profile_synced = false
|
||||||
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
|
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
|
||||||
@@ -797,6 +803,8 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
|||||||
_style_account_avatar(account_button)
|
_style_account_avatar(account_button)
|
||||||
if account_chevron_button != null:
|
if account_chevron_button != null:
|
||||||
_style_account_chevron(account_chevron_button)
|
_style_account_chevron(account_chevron_button)
|
||||||
|
if credits_badge != null:
|
||||||
|
credits_badge.refresh_theme_controls()
|
||||||
if sidebar != null:
|
if sidebar != null:
|
||||||
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
||||||
if header_panel != null:
|
if header_panel != null:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
|||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||||
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
||||||
|
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||||
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
||||||
@@ -100,6 +101,7 @@ var flashcard_prefill_dialog: Control
|
|||||||
var bookmark_button: Button
|
var bookmark_button: Button
|
||||||
var app_feedback_button: Button
|
var app_feedback_button: Button
|
||||||
var theme_button: Button
|
var theme_button: Button
|
||||||
|
var credits_badge: PanelContainer
|
||||||
var question_toolbar: HFlowContainer
|
var question_toolbar: HFlowContainer
|
||||||
var header_stack: VBoxContainer
|
var header_stack: VBoxContainer
|
||||||
var header_row: HBoxContainer
|
var header_row: HBoxContainer
|
||||||
@@ -417,6 +419,8 @@ func _ready() -> void:
|
|||||||
Style.style_chip(app_feedback_button, Style.CYAN)
|
Style.style_chip(app_feedback_button, Style.CYAN)
|
||||||
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
||||||
|
|
||||||
|
credits_badge = CreditsBadge.new()
|
||||||
|
|
||||||
# Every action is directly visible - no "More" overflow or popups hiding
|
# Every action is directly visible - no "More" overflow or popups hiding
|
||||||
# controls behind an extra click. No text labels either (their varying widths
|
# controls behind an extra click. No text labels either (their varying widths
|
||||||
# broke even spacing when a button hid itself) - each button's existing
|
# broke even spacing when a button hid itself) - each button's existing
|
||||||
@@ -434,6 +438,7 @@ func _ready() -> void:
|
|||||||
question_toolbar.add_child(notes_button)
|
question_toolbar.add_child(notes_button)
|
||||||
question_toolbar.add_child(theme_button)
|
question_toolbar.add_child(theme_button)
|
||||||
question_toolbar.add_child(app_feedback_button)
|
question_toolbar.add_child(app_feedback_button)
|
||||||
|
question_toolbar.add_child(credits_badge)
|
||||||
|
|
||||||
header_toolbar_host = HBoxContainer.new()
|
header_toolbar_host = HBoxContainer.new()
|
||||||
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
||||||
@@ -802,9 +807,10 @@ func _refresh_responsive_layout() -> void:
|
|||||||
header_toolbar_host.reparent(target_parent)
|
header_toolbar_host.reparent(target_parent)
|
||||||
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
||||||
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
# 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps
|
# 12 bare 44px icon buttons + the wider 92px Feedback button + the 150px
|
||||||
# between all 12 - anything less than this wraps onto a second row.
|
# points badge, with 8px gaps between all 14 - anything less than this
|
||||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0
|
# wraps onto a second row.
|
||||||
|
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 900.0
|
||||||
if size.x < 560.0:
|
if size.x < 560.0:
|
||||||
nav_palette.columns = 5
|
nav_palette.columns = 5
|
||||||
else:
|
else:
|
||||||
@@ -1076,6 +1082,8 @@ func _apply_question_theme() -> void:
|
|||||||
_style_theme_toggle_button(dark)
|
_style_theme_toggle_button(dark)
|
||||||
if calculator_panel != null:
|
if calculator_panel != null:
|
||||||
calculator_panel.refresh_theme_controls()
|
calculator_panel.refresh_theme_controls()
|
||||||
|
if credits_badge != null:
|
||||||
|
credits_badge.refresh_theme_controls()
|
||||||
if skip_button != null:
|
if skip_button != null:
|
||||||
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
||||||
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||||
|
|||||||
Reference in New Issue
Block a user