Files
step3/scripts/ui/main.gd
T
hzandClaude Sonnet 5 012d0cf196 Feature-flag RZ Shop to UAT and remove the redundant account menu
RZ Shop joins the other UAT-only features (slot machine, flashcards,
CCS, dynamic questions) - hidden from the sidebar and no longer
reachable by clicking the header coin/rank badge in production. Also
drop the header's account avatar+chevron dropdown entirely: its only
contents (Profile, Settings, Log out) already live in the sidebar.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 08:33:17 -05:00

864 lines
33 KiB
GDScript

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
var header_panel: PanelContainer
var header_title: Label
var environment_badge: Label
var app_menu: PanelContainer
var menu_exit_button: Button
var theme_button: Button
var feedback_button: Button
var feedback_dialog: Control
var notes_dialog: Control
var session_expiry_dialog: Control
var legal_document_dialog: Control
var theme_service
var current_screen_name := ""
var screens_margin: MarginContainer
var screens_host: MarginContainer
var shell_root: HBoxContainer
var sidebar: PanelContainer
var sidebar_content: VBoxContainer
var drawer_backdrop: ColorRect
var drawer_open := false
var menu_button: Button
var credits_badge: PanelContainer
var nav_buttons: Dictionary = {}
var current_nav_key := "home"
var compact_shell := false
var app_background: ColorRect
func _ready() -> void:
theme_service = get_node("/root/UIThemeService")
Style.apply_app_theme(self)
_build_ui()
theme_service.theme_changed.connect(_on_theme_changed)
resized.connect(_update_responsive_layout)
_update_responsive_layout()
show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login")
func _update_responsive_layout() -> void:
compact_shell = size.x < 900.0
if sidebar != null:
sidebar.visible = AuthState.is_logged_in and not compact_shell and _screen_uses_shell(current_screen_name)
if menu_button != null:
menu_button.visible = AuthState.is_logged_in and compact_shell and _screen_uses_shell(current_screen_name)
_apply_screen_margins(current_screen_name)
if feedback_button != null:
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
if drawer_open and not compact_shell:
_close_drawer()
if app_menu != null:
app_menu.custom_minimum_size.x = minf(340.0, maxf(280.0, size.x - 32.0))
if app_menu.visible:
app_menu.size = app_menu.custom_minimum_size
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
func show_screen(screen_name: String, payload: Variant = null) -> void:
_close_app_menu()
_close_drawer()
for child in screens_host.get_children():
if child.visible and child.has_method("on_hide"):
child.on_hide()
child.visible = false
var screen = screens.get(screen_name)
if screen == null:
return
current_screen_name = screen_name
_update_header_title(screen_name)
_apply_screen_margins(screen_name)
screen.visible = true
if header_panel != null:
header_panel.visible = _screen_uses_shell(screen_name)
_update_shell_visibility(screen_name)
_update_active_navigation(screen_name, payload)
if screen.has_method("on_show"):
screen.on_show(payload)
func _apply_screen_margins(screen_name: String) -> void:
if screens_margin == null or screen_name.is_empty():
return
var is_fullscreen_study := screen_name == "player"
var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44))
screens_margin.add_theme_constant_override("margin_left", margin)
screens_margin.add_theme_constant_override("margin_right", margin)
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else (12 if is_fullscreen_study else 28))
func _build_ui() -> void:
app_background = ColorRect.new()
app_background.color = Brand.background_color(UIThemeService.is_dark_mode())
app_background.material = Brand.background_material(UIThemeService.is_dark_mode())
app_background.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(app_background)
shell_root = HBoxContainer.new()
shell_root.name = "Root"
shell_root.set_anchors_preset(Control.PRESET_FULL_RECT)
shell_root.add_theme_constant_override("separation", 0)
add_child(shell_root)
sidebar = _build_sidebar()
shell_root.add_child(sidebar)
var main_column := VBoxContainer.new()
main_column.name = "MainColumn"
main_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
main_column.add_theme_constant_override("separation", 0)
shell_root.add_child(main_column)
header_panel = PanelContainer.new()
header_panel.name = "Header"
header_panel.add_theme_stylebox_override("panel", _top_bar_box())
main_column.add_child(header_panel)
var header := HBoxContainer.new()
header.custom_minimum_size = Vector2(0, 80)
header.add_theme_constant_override("separation", 14)
header.alignment = BoxContainer.ALIGNMENT_CENTER
header_panel.add_child(header)
menu_button = Button.new()
menu_button.icon = preload("res://assets/icons/lucide/menu.svg")
menu_button.add_theme_constant_override("icon_max_width", 24)
menu_button.tooltip_text = "Open navigation"
menu_button.custom_minimum_size = Vector2(48, 48)
menu_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
menu_button.visible = false
menu_button.pressed.connect(_open_drawer)
header.add_child(menu_button)
header_title = Label.new()
header_title.add_theme_font_override("font", Brand.font)
header_title.add_theme_font_size_override("font_size", 22)
header_title.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
header.add_child(header_title)
environment_badge = Label.new()
environment_badge.text = "UAT"
environment_badge.tooltip_text = "User acceptance testing environment"
environment_badge.visible = Brand.is_uat_environment()
environment_badge.add_theme_font_override("font", Brand.font)
environment_badge.add_theme_font_size_override("font_size", 13)
environment_badge.add_theme_color_override("font_color", Color("052e16"))
environment_badge.add_theme_stylebox_override("normal", _environment_badge_box())
header.add_child(environment_badge)
var header_spacer := Control.new()
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(header_spacer)
feedback_button = Button.new()
feedback_button.text = "Feedback"
feedback_button.tooltip_text = "Send general feedback or report a bug"
feedback_button.custom_minimum_size = Vector2(124, 42)
feedback_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_header_feedback_button(feedback_button)
feedback_button.pressed.connect(open_feedback_dialog)
header.add_child(feedback_button)
theme_button = Button.new()
theme_button.tooltip_text = "Switch color theme"
theme_button.custom_minimum_size = Vector2(44, 44)
theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
theme_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
theme_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
theme_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
theme_button.focus_mode = Control.FOCUS_NONE
theme_button.pressed.connect(theme_service.toggle_theme)
_style_shell_theme_button(theme_button)
header.add_child(theme_button)
_refresh_theme_button()
credits_badge = CreditsBadge.new()
credits_badge.store_requested.connect(func():
if Brand.is_uat_environment():
show_screen("rz_store"))
header.add_child(credits_badge)
var screens_container := MarginContainer.new()
screens_margin = screens_container
screens_host = screens_container
screens_container.name = "Screens"
screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
screens_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
screens_container.add_theme_constant_override("margin_left", 24)
screens_container.add_theme_constant_override("margin_right", 24)
screens_container.add_theme_constant_override("margin_top", 18)
screens_container.add_theme_constant_override("margin_bottom", 22)
main_column.add_child(screens_container)
var menu := preload("res://scripts/ui/main_menu.gd").new()
menu.name = "MainMenu"
menu.main = self
screens_container.add_child(menu)
screens["menu"] = menu
var login := preload("res://scripts/ui/login_screen.gd").new()
login.name = "LoginScreen"
login.main = self
screens_container.add_child(login)
screens["login"] = login
var legal_consent := preload("res://scripts/ui/legal_consent_screen.gd").new()
legal_consent.name = "LegalConsentScreen"
legal_consent.main = self
screens_container.add_child(legal_consent)
screens["legal_consent"] = legal_consent
var dashboard := preload("res://scripts/ui/dashboard_screen.gd").new()
dashboard.name = "DashboardScreen"
dashboard.main = self
screens_container.add_child(dashboard)
screens["dashboard"] = dashboard
var selector := preload("res://scripts/ui/study_selector.gd").new()
selector.name = "StudySelector"
selector.main = self
selector.stage_changed.connect(_on_selector_stage_changed)
screens_container.add_child(selector)
screens["selection"] = selector
var dynamic_questions := preload("res://scripts/ui/dynamic_questions_screen.gd").new()
dynamic_questions.name = "DynamicQuestionsScreen"
dynamic_questions.main = self
screens_container.add_child(dynamic_questions)
screens["dynamic_questions"] = dynamic_questions
var map := preload("res://scripts/ui/study_map.gd").new()
map.name = "StudyMap"
map.main = self
screens_container.add_child(map)
screens["map"] = map
var setup := preload("res://scripts/ui/block_setup.gd").new()
setup.name = "BlockSetup"
setup.main = self
screens_container.add_child(setup)
screens["setup"] = setup
var player := preload("res://scripts/ui/question_player.gd").new()
player.name = "QuestionPlayer"
player.main = self
screens_container.add_child(player)
screens["player"] = player
var review := preload("res://scripts/ui/review_results.gd").new()
review.name = "ReviewResults"
review.main = self
screens_container.add_child(review)
screens["review"] = review
var review_queue_results := preload("res://scripts/ui/review_queue_results.gd").new()
review_queue_results.name = "ReviewQueueResults"
review_queue_results.main = self
screens_container.add_child(review_queue_results)
screens["review_queue_results"] = review_queue_results
var simulation_break := preload("res://scripts/ui/simulation_break.gd").new()
simulation_break.name = "SimulationBreak"
simulation_break.main = self
screens_container.add_child(simulation_break)
screens["simulation_break"] = simulation_break
var simulation_results := preload("res://scripts/ui/simulation_results.gd").new()
simulation_results.name = "SimulationResults"
simulation_results.main = self
screens_container.add_child(simulation_results)
screens["simulation_results"] = simulation_results
var simulation_review := preload("res://scripts/ui/simulation_review.gd").new()
simulation_review.name = "SimulationReview"
simulation_review.main = self
screens_container.add_child(simulation_review)
screens["simulation_review"] = simulation_review
var simulation_history := preload("res://scripts/ui/simulation_history.gd").new()
simulation_history.name = "SimulationHistory"
simulation_history.main = self
screens_container.add_child(simulation_history)
screens["simulation_history"] = simulation_history
var ccs_hub := preload("res://scripts/ui/ccs_hub.gd").new()
ccs_hub.name = "CcsHub"; ccs_hub.main = self; screens_container.add_child(ccs_hub); screens["ccs_hub"] = ccs_hub
var ccs_player := preload("res://scripts/ui/ccs_player.gd").new()
ccs_player.name = "CcsPlayer"; ccs_player.main = self; screens_container.add_child(ccs_player); screens["ccs_player"] = ccs_player
var ccs_review := preload("res://scripts/ui/ccs_review.gd").new()
ccs_review.name = "CcsReview"; ccs_review.main = self; screens_container.add_child(ccs_review); screens["ccs_review"] = ccs_review
var ccs_history := preload("res://scripts/ui/ccs_history.gd").new()
ccs_history.name = "CcsHistory"; ccs_history.main = self; screens_container.add_child(ccs_history); screens["ccs_history"] = ccs_history
var flashcards_hub := preload("res://scripts/ui/flashcards_hub.gd").new()
flashcards_hub.name = "FlashcardsHub"; flashcards_hub.main = self
screens_container.add_child(flashcards_hub); screens["flashcards_hub"] = flashcards_hub
var flashcards_study := preload("res://scripts/ui/flashcards_study.gd").new()
flashcards_study.name = "FlashcardsStudy"; flashcards_study.main = self
screens_container.add_child(flashcards_study); screens["flashcards_study"] = flashcards_study
var profile := preload("res://scripts/ui/progress_profile.gd").new()
profile.name = "ProgressProfile"
profile.main = self
screens_container.add_child(profile)
screens["profile"] = profile
var settings := preload("res://scripts/ui/settings_screen.gd").new()
settings.name = "SettingsScreen"
settings.main = self
screens_container.add_child(settings)
screens["settings"] = settings
var notes := preload("res://scripts/ui/notes_screen.gd").new()
notes.name = "NotesScreen"
notes.main = self
screens_container.add_child(notes)
screens["notes"] = notes
var bookmarks := preload("res://scripts/ui/bookmarks_screen.gd").new()
bookmarks.name = "BookmarksScreen"
bookmarks.main = self
screens_container.add_child(bookmarks)
screens["bookmarks"] = bookmarks
var donations := preload("res://scripts/ui/donation_screen.gd").new()
donations.name = "DonationScreen"
donations.main = self
screens_container.add_child(donations)
screens["donations"] = donations
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
rz_store.name = "RzCoinStore"
rz_store.main = self
screens_container.add_child(rz_store)
screens["rz_store"] = rz_store
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog"
add_child(feedback_dialog)
notes_dialog = preload("res://scripts/ui/notes_dialog.gd").new()
notes_dialog.name = "NotesDialog"
add_child(notes_dialog)
session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new()
session_expiry_dialog.name = "SessionExpiryDialog"
session_expiry_dialog.logout_requested.connect(_logout)
add_child(session_expiry_dialog)
legal_document_dialog = preload("res://scripts/ui/legal_document_dialog.gd").new()
legal_document_dialog.name = "LegalDocumentDialog"
add_child(legal_document_dialog)
_build_drawer()
AuthState.auth_changed.connect(_on_auth_changed)
AuthState.session_expired.connect(_on_session_expired)
_refresh_header_controls()
_build_app_menu()
func _build_sidebar() -> PanelContainer:
var panel := PanelContainer.new()
panel.name = "Sidebar"
panel.custom_minimum_size = Vector2(304, 0)
panel.add_theme_stylebox_override("panel", _sidebar_box())
sidebar_content = VBoxContainer.new()
sidebar_content.add_theme_constant_override("separation", 8)
panel.add_child(sidebar_content)
_populate_navigation(sidebar_content, true)
return panel
func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void:
if include_brand:
var brand_row := HBoxContainer.new()
brand_row.custom_minimum_size = Vector2(0, 56)
brand_row.add_theme_constant_override("separation", 10)
container.add_child(brand_row)
var mark := TextureRect.new()
mark.texture = Brand.ranaze_mark_texture()
mark.custom_minimum_size = Vector2(46, 46)
mark.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
mark.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
brand_row.add_child(mark)
var wordmark := HBoxContainer.new()
wordmark.add_theme_constant_override("separation", 0)
brand_row.add_child(wordmark)
var rana := Label.new()
rana.text = "Rana"
rana.add_theme_font_override("font", Brand.font)
rana.add_theme_font_size_override("font_size", 27)
wordmark.add_child(rana)
var ze := Label.new()
ze.text = "Ze"
ze.add_theme_font_override("font", Brand.font)
ze.add_theme_font_size_override("font_size", 27)
ze.add_theme_color_override("font_color", Brand.SKY_BLUE)
wordmark.add_child(ze)
container.add_child(HSeparator.new())
_add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true)
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
_add_nav_button(container, "dynamic_questions", "Dynamic Questions", "activity", true, Brand.is_uat_environment())
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true, Brand.is_uat_environment())
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment())
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
container.add_child(spacer)
container.add_child(HSeparator.new())
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "rz_store", "RZ Shop", "star", true, Brand.is_uat_environment())
_add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true)
func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool, visible_flag: bool = true) -> void:
var button := Button.new()
button.text = label_text
button.name = "Nav%s" % key.to_pascal_case()
button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name)
button.expand_icon = true
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(0, 58)
button.add_theme_constant_override("icon_max_width", 25)
button.add_theme_constant_override("h_separation", 18)
button.add_theme_font_override("font", Brand.font)
button.add_theme_font_size_override("font_size", 18)
button.disabled = not enabled
button.tooltip_text = "Coming Soon" if not enabled else label_text
button.visible = visible_flag
button.pressed.connect(_on_navigation_pressed.bind(key))
container.add_child(button)
if not nav_buttons.has(key):
nav_buttons[key] = []
var instances: Array = nav_buttons[key]
instances.append(button)
nav_buttons[key] = instances
_style_nav_button(button, key == current_nav_key)
func _on_navigation_pressed(key: String) -> void:
match key:
"home": show_screen("dashboard")
"question_bank": show_screen("selection", "question_bank")
"dynamic_questions": show_screen("dynamic_questions")
"exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub")
"flashcards": show_screen("flashcards_study")
"performance": show_screen("profile")
"study_plan": show_screen("map")
"settings": show_screen("settings")
"notes": show_screen("notes")
"bookmarks": show_screen("bookmarks")
"donations": show_screen("donations")
"rz_store": show_screen("rz_store")
"logout": _logout()
func _update_header_title(screen_name: String) -> void:
if header_title == null:
return
var titles := {
"dashboard": "Dashboard",
"selection": "Question Bank",
"dynamic_questions": "Dynamic Questions",
"simulation_history": "Exam Simulations",
"simulation_results": "Exam Simulations",
"ccs_hub": "CCS Cases",
"flashcards_hub": "Flashcards",
"flashcards_study": "Flashcards",
"profile": "Profile",
"settings": "Settings",
"notes": "Notes",
"bookmarks": "Bookmarks",
"donations": "Support RanaZe"
}
header_title.text = str(titles.get(screen_name, ""))
func _build_drawer() -> void:
drawer_backdrop = ColorRect.new()
drawer_backdrop.visible = false
drawer_backdrop.top_level = true
drawer_backdrop.color = Color(0, 0, 0, 0.62)
drawer_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
drawer_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
drawer_backdrop.gui_input.connect(_on_drawer_backdrop_input)
add_child(drawer_backdrop)
var drawer := PanelContainer.new()
drawer.custom_minimum_size = Vector2(304, 0)
drawer.set_anchors_preset(Control.PRESET_LEFT_WIDE)
drawer.offset_right = 304
drawer.add_theme_stylebox_override("panel", _sidebar_box())
drawer.mouse_filter = Control.MOUSE_FILTER_STOP
drawer_backdrop.add_child(drawer)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
drawer.add_child(content)
_populate_navigation(content, true)
func _open_drawer() -> void:
if drawer_backdrop == null:
return
drawer_open = true
drawer_backdrop.visible = true
func _close_drawer() -> void:
drawer_open = false
if drawer_backdrop != null:
drawer_backdrop.visible = false
func _on_drawer_backdrop_input(event: InputEvent) -> void:
var click := event as InputEventMouseButton
if click != null and click.pressed:
_close_drawer()
func _screen_uses_shell(screen_name: String) -> bool:
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
func _update_shell_visibility(screen_name: String) -> void:
var show_shell := _screen_uses_shell(screen_name)
if sidebar != null:
sidebar.visible = show_shell and not compact_shell
if header_panel != null:
header_panel.visible = show_shell
if menu_button != null:
menu_button.visible = show_shell and compact_shell
func _update_active_navigation(screen_name: String, payload: Variant = null) -> void:
var key := NavigationRoutes.key_for_screen(screen_name, payload)
current_nav_key = key
for nav_key in nav_buttons:
for button in nav_buttons[nav_key]:
_style_nav_button(button, nav_key == current_nav_key)
func _on_selector_stage_changed(stage_name: String) -> void:
if current_screen_name == "selection":
_update_active_navigation("selection", stage_name)
func _style_nav_button(button: Button, active: bool) -> void:
var dark := UIThemeService.is_dark_mode()
var normal := StyleBoxFlat.new()
normal.bg_color = (Color("14255b") if dark else Color("e5edff")) if active else Color(0, 0, 0, 0)
normal.set_corner_radius_all(10)
normal.content_margin_left = 18
normal.content_margin_right = 18
button.add_theme_stylebox_override("normal", normal)
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color("172d68") if dark else Color("d8e5ff")
button.add_theme_stylebox_override("hover", hover)
var disabled := normal.duplicate() as StyleBoxFlat
disabled.bg_color = Color(0, 0, 0, 0)
button.add_theme_stylebox_override("disabled", disabled)
button.add_theme_color_override("font_color", (Color("f8fafc") if dark else Color("0f172a")) if active else (Color("b8c2d6") if dark else Color("53627a")))
button.add_theme_color_override("icon_normal_color", Brand.ROYAL_BLUE if active else (Color("b8c2d6") if dark else Color("53627a")))
button.add_theme_color_override("icon_hover_color", Brand.SKY_BLUE)
button.add_theme_color_override("font_disabled_color", Color("596982") if dark else Color("94a3b8"))
button.add_theme_color_override("icon_disabled_color", Color("596982") if dark else Color("94a3b8"))
func _sidebar_box() -> StyleBoxFlat:
var dark := UIThemeService.is_dark_mode()
var box := Brand.shell_box(dark, "right")
box.content_margin_left = 28
box.content_margin_right = 28
box.content_margin_top = 12
box.content_margin_bottom = 26
return box
func _top_bar_box() -> StyleBoxFlat:
var dark := UIThemeService.is_dark_mode()
var box := Brand.shell_box(dark, "bottom")
box.content_margin_left = 28
box.content_margin_right = 28
return box
func _environment_badge_box() -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("86efac")
box.set_corner_radius_all(6)
box.content_margin_left = 9
box.content_margin_right = 9
box.content_margin_top = 4
box.content_margin_bottom = 4
return box
func _style_header_feedback_button(button: Button) -> void:
var dark := UIThemeService.is_dark_mode()
var normal := Style.button_box(Color(0, 0, 0, 0), Brand.ROYAL_BLUE)
var hover := Style.button_box(Color(Brand.ROYAL_BLUE, 0.16), Brand.SKY_BLUE)
var pressed := Style.button_box(Color(Brand.ROYAL_BLUE, 0.24), Brand.SKY_BLUE)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_color_override("font_color", Brand.ROYAL_BLUE if not dark else Brand.SKY_BLUE)
button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE)
button.add_theme_color_override("icon_normal_color", Brand.ROYAL_BLUE if not dark else Brand.SKY_BLUE)
button.add_theme_color_override("icon_hover_color", Brand.SKY_BLUE)
func _style_shell_theme_button(button: Button) -> void:
var dark := UIThemeService.is_dark_mode()
var normal := StyleBoxFlat.new()
normal.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE
normal.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER
normal.set_border_width_all(1)
normal.set_corner_radius_all(22)
normal.content_margin_left = 0
normal.content_margin_right = 0
normal.content_margin_top = 0
normal.content_margin_bottom = 0
var hover := normal.duplicate() as StyleBoxFlat
hover.border_color = Brand.AMBER
var focus := StyleBoxEmpty.new()
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", hover)
button.add_theme_stylebox_override("focus", focus)
button.add_theme_constant_override("h_separation", 0)
button.add_theme_color_override("font_color", Brand.AMBER)
button.add_theme_color_override("font_hover_color", Brand.AMBER)
func _input(event: InputEvent) -> void:
if not AuthState.is_logged_in or current_screen_name == "login":
return
if event.is_action_pressed("ui_cancel"):
if drawer_open:
_close_drawer()
get_viewport().set_input_as_handled()
return
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
_toggle_app_menu()
get_viewport().set_input_as_handled()
func _refresh_header_controls() -> void:
if feedback_button != null:
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
feedback_button.icon = preload("res://assets/icons/lucide/message-square.svg")
feedback_button.add_theme_constant_override("icon_max_width", 21)
func open_feedback_dialog() -> void:
if not AuthState.is_logged_in or feedback_dialog == null:
return
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
func open_legal_document(kind: String) -> void:
legal_document_dialog.show_document(kind)
func _on_auth_changed() -> void:
_refresh_header_controls()
if AuthState.is_logged_in:
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"]:
show_screen("legal_consent")
func open_notes_dialog() -> void:
if not AuthState.is_logged_in or notes_dialog == null:
return
if current_screen_name != "player" or BlockSessionService.session_kind == "simulation":
return
notes_dialog.show_dialog()
func _on_theme_changed(is_dark_mode: bool) -> void:
Style.refresh_tree(self, not is_dark_mode)
_refresh_theme_button()
if app_background != null:
app_background.color = Brand.background_color(is_dark_mode)
app_background.material = Brand.background_material(is_dark_mode)
if feedback_button != null:
_style_header_feedback_button(feedback_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:
header_panel.add_theme_stylebox_override("panel", _top_bar_box())
if header_title != null:
header_title.add_theme_color_override("font_color", Brand.text_color(is_dark_mode))
if theme_button != null:
_style_shell_theme_button(theme_button)
for nav_key in nav_buttons:
for button in nav_buttons[nav_key]:
_style_nav_button(button, nav_key == current_nav_key)
var selector = screens.get("selection")
if selector != null and selector.has_method("refresh_theme_controls"):
selector.refresh_theme_controls()
var donations = screens.get("donations")
if donations != null and donations.has_method("refresh_theme_controls"):
donations.refresh_theme_controls()
var player = screens.get("player")
if player != null and player.has_method("refresh_theme_controls"):
player.refresh_theme_controls()
func _refresh_theme_button() -> void:
if theme_button == null:
return
Brand.set_theme_toggle_icon(theme_button, theme_service.is_dark_mode())
theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode"
_style_shell_theme_button(theme_button)
func _logout() -> void:
_perform_logout("")
func _on_session_expired(_reason: String) -> void:
_perform_logout("Your session expired. Sign in again.")
func _perform_logout(login_message: String) -> void:
_close_app_menu()
if session_expiry_dialog != null and session_expiry_dialog.has_method("dismiss_for_logout"):
session_expiry_dialog.dismiss_for_logout()
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation":
BlockSessionService.block_started = false
QuestionBankService.clear_questions()
if AuthState.is_logged_in or not AuthState.access_token.is_empty():
AuthState.clear_session()
show_screen("login", login_message)
func _build_app_menu() -> void:
app_menu = PanelContainer.new()
app_menu.visible = false
app_menu.top_level = true
app_menu.mouse_filter = Control.MOUSE_FILTER_STOP
app_menu.custom_minimum_size = Vector2(340, 290)
app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN))
add_child(app_menu)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 12)
app_menu.add_child(content)
var title := Label.new()
title.text = "Paused"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(title, 28, Style.TEXT)
content.add_child(title)
var resume := Button.new()
resume.text = "Resume"
resume.custom_minimum_size = Vector2(0, 48)
Style.style_primary_button(resume)
UISoundService.set_button_sound(resume, "close")
resume.pressed.connect(_close_app_menu)
content.add_child(resume)
var profile_button := Button.new()
profile_button.text = "Profile"
profile_button.custom_minimum_size = Vector2(0, 48)
Style.style_chip(profile_button, Style.CYAN)
UISoundService.set_button_sound(profile_button, "open")
profile_button.pressed.connect(_navigate_from_pause.bind("profile"))
content.add_child(profile_button)
var settings_button := Button.new()
settings_button.text = "Settings"
settings_button.custom_minimum_size = Vector2(0, 48)
Style.style_chip(settings_button, Style.CYAN)
UISoundService.set_button_sound(settings_button, "open")
settings_button.pressed.connect(_navigate_from_pause.bind("settings"))
content.add_child(settings_button)
menu_exit_button = Button.new()
menu_exit_button.text = "Exit Run"
menu_exit_button.custom_minimum_size = Vector2(0, 48)
UISoundService.set_button_sound(menu_exit_button, "back")
menu_exit_button.pressed.connect(exit_current_run)
content.add_child(menu_exit_button)
var logout := Button.new()
logout.text = "Log Out"
logout.custom_minimum_size = Vector2(0, 48)
Style.style_chip(logout, Style.RED)
UISoundService.set_button_sound(logout, "back")
logout.pressed.connect(_logout)
content.add_child(logout)
func _toggle_app_menu() -> void:
if app_menu == null:
return
if app_menu.visible:
UISoundService.play_sound("close")
_close_app_menu()
else:
UISoundService.play_sound("open")
app_menu.visible = true
app_menu.size = app_menu.custom_minimum_size
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
if menu_exit_button.visible:
menu_exit_button.text = (
"Abandon CCS Case" if current_screen_name == "ccs_player"
else "Exit Study Session" if current_screen_name == "flashcards_study"
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
else "Exit Run")
Style.style_chip(menu_exit_button, Style.AMBER)
if current_screen_name == "player": BlockSessionService.pause_current_question_timer()
func _close_app_menu() -> void:
if app_menu != null:
app_menu.visible = false
BlockSessionService.resume_current_question_timer()
func _navigate_from_pause(target_screen: String) -> void:
var return_screen := current_screen_name
show_screen(target_screen, {"return_screen": return_screen})
if return_screen == "player":
BlockSessionService.pause_current_question_timer()
func exit_current_run() -> void:
_close_app_menu()
if current_screen_name == "ccs_player":
var ccs_player = screens.get("ccs_player")
if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case()
return
if current_screen_name == "flashcards_study":
show_screen("flashcards_hub")
return
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation":
BlockSessionService.block_started = false
show_screen("dashboard")