diff --git a/project.godot b/project.godot index 122d1a6..3149262 100644 --- a/project.godot +++ b/project.godot @@ -40,6 +40,7 @@ NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" +CreditsService="*res://scripts/autoload/credits_service.gd" FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd new file mode 100644 index 0000000..4f05a7a --- /dev/null +++ b/scripts/autoload/credits_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() diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 9e4d369..705013d 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -507,6 +507,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: var rating: Dictionary = parsed.get("rating", {}) _update_question_elo(question_id, question_elo) _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) question_rating_updated.emit(question_id, question_elo, rating) diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd new file mode 100644 index 0000000..c004aad --- /dev/null +++ b/scripts/ui/credits_badge.gd @@ -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 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 1e5989c..dacb8dc 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -3,6 +3,7 @@ extends Control const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") +const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") var screens := {} var _account_profile_synced := false @@ -29,6 +30,7 @@ var drawer_open := false var menu_button: Button var account_button: Button var account_chevron_button: Button +var credits_badge: PanelContainer var account_menu: PanelContainer var nav_buttons: Dictionary = {} var current_nav_key := "home" @@ -204,6 +206,9 @@ func _build_ui() -> void: _style_account_chevron(account_chevron_button) header.add_child(account_chevron_button) + credits_badge = CreditsBadge.new() + header.add_child(credits_badge) + var screens_container := MarginContainer.new() screens_margin = screens_container screens_host = screens_container @@ -769,6 +774,7 @@ func _on_auth_changed() -> void: if not _account_profile_synced: _account_profile_synced = true AccountProfileService.refresh() + CreditsService.refresh() else: _account_profile_synced = false 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) if account_chevron_button != null: _style_account_chevron(account_chevron_button) + if credits_badge != null: + credits_badge.refresh_theme_controls() if sidebar != null: sidebar.add_theme_stylebox_override("panel", _sidebar_box()) if header_panel != null: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index b86078f..58865f8 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.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_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.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 app_feedback_button: Button var theme_button: Button +var credits_badge: PanelContainer var question_toolbar: HFlowContainer var header_stack: VBoxContainer var header_row: HBoxContainer @@ -417,6 +419,8 @@ func _ready() -> void: Style.style_chip(app_feedback_button, Style.CYAN) 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 # controls behind an extra click. No text labels either (their varying widths # 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(theme_button) question_toolbar.add_child(app_feedback_button) + question_toolbar.add_child(credits_badge) header_toolbar_host = HBoxContainer.new() 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.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - # 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps - # between all 12 - anything less than this wraps onto a second row. - header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0 + # 12 bare 44px icon buttons + the wider 92px Feedback button + the 150px + # points badge, with 8px gaps between all 14 - anything less than this + # wraps onto a second row. + header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 900.0 if size.x < 560.0: nav_palette.columns = 5 else: @@ -1076,6 +1082,8 @@ func _apply_question_theme() -> void: _style_theme_toggle_button(dark) if calculator_panel != null: calculator_panel.refresh_theme_controls() + if credits_badge != null: + credits_badge.refresh_theme_controls() if skip_button != null: var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())