Files
step3/scripts/ui/main.gd
T
hz 09b670d5d4 Close the gap between the flashcards study screen and the mockup
Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
2026-07-22 00:43:52 -05:00

934 lines
35 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")
var screens := {}
var header_panel: PanelContainer
var header_title: Label
var environment_badge: Label
var app_menu: PanelContainer
var menu_exit_button: Button
var sound_toggle: CheckButton
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 account_button: Button
var account_chevron_button: Button
var account_menu: 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 account_chevron_button != null:
account_chevron_button.visible = size.x >= 480.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_account_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 in ["player", "flashcards_study"]
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 = str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat"
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()
account_button = Button.new()
account_button.tooltip_text = "Open account menu"
account_button.custom_minimum_size = Vector2(44, 44)
account_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
account_button.pressed.connect(_toggle_account_menu)
_style_account_avatar(account_button)
header.add_child(account_button)
account_chevron_button = Button.new()
account_chevron_button.icon = preload("res://assets/icons/lucide/chevron-down.svg")
account_chevron_button.tooltip_text = "Open account menu"
account_chevron_button.custom_minimum_size = Vector2(28, 44)
account_chevron_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
account_chevron_button.add_theme_constant_override("icon_max_width", 17)
account_chevron_button.pressed.connect(_toggle_account_menu)
_style_account_chevron(account_chevron_button)
header.add_child(account_chevron_button)
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 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
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_account_menu()
_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, "ccs", "CCS Cases", "stethoscope", true)
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
_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, "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) -> 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.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")
"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")
"logout": _logout()
func _update_header_title(screen_name: String) -> void:
if header_title == null:
return
var titles := {
"dashboard": "Dashboard",
"selection": "Question Bank",
"simulation_history": "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_account_menu() -> void:
account_menu = PanelContainer.new()
account_menu.visible = false
account_menu.top_level = true
account_menu.custom_minimum_size = Vector2(230, 230)
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
add_child(account_menu)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
account_menu.add_child(content)
for spec in [["Profile", "user-round", "performance"], ["Settings", "settings", "settings"], ["Log out", "log-out", "logout"]]:
var button := Button.new()
button.text = spec[0]
button.icon = load("res://assets/icons/lucide/%s.svg" % spec[1])
button.add_theme_constant_override("icon_max_width", 22)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(0, 46)
button.pressed.connect(_on_navigation_pressed.bind(spec[2]))
content.add_child(button)
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 _toggle_account_menu() -> void:
if account_menu == null:
return
if account_menu.visible:
_close_account_menu()
return
account_menu.visible = true
account_menu.size = account_menu.custom_minimum_size
account_menu.position = Vector2(
get_viewport_rect().size.x - account_menu.size.x - 24,
82
)
func _close_account_menu() -> void:
if account_menu != null:
account_menu.visible = false
func _screen_uses_shell(screen_name: String) -> bool:
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "flashcards_study"]
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_account_avatar(button: Button) -> void:
var dark := UIThemeService.is_dark_mode()
var normal := StyleBoxFlat.new()
normal.bg_color = Color("26324f") if dark else Color("dce5f5")
normal.corner_radius_top_left = 22
normal.corner_radius_top_right = 22
normal.corner_radius_bottom_left = 22
normal.corner_radius_bottom_right = 22
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color("344465") if dark else Color("cad7ed")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", hover)
button.add_theme_color_override("font_color", Color("f8fafc") if dark else Color("172033"))
button.add_theme_color_override("font_hover_color", Color("ffffff") if dark else Color("0f172a"))
func _style_account_chevron(button: Button) -> void:
var empty := StyleBoxEmpty.new()
button.add_theme_stylebox_override("normal", empty)
button.add_theme_stylebox_override("hover", empty)
button.add_theme_stylebox_override("pressed", empty)
button.add_theme_color_override("icon_normal_color", Style.MUTED)
button.add_theme_color_override("icon_hover_color", Style.TEXT)
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 account_menu != null and account_menu.visible:
_close_account_menu()
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 account_button != null:
account_button.text = _account_initials(AuthState.username)
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 _account_initials(username: String) -> String:
var cleaned := ""
for character in username:
if character.to_lower() != character.to_upper():
cleaned += character
elif character.is_valid_int():
cleaned += character
if cleaned.length() >= 2:
break
return cleaned.to_upper() if not cleaned.is_empty() else "ME"
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 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 account_menu != null:
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(is_dark_mode))
if feedback_button != null:
_style_header_feedback_button(feedback_button)
if account_button != null:
_style_account_avatar(account_button)
if account_chevron_button != null:
_style_account_chevron(account_chevron_button)
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, 230)
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)
sound_toggle = CheckButton.new()
sound_toggle.text = "UI Sounds"
sound_toggle.button_pressed = UISoundService.are_sounds_enabled()
sound_toggle.custom_minimum_size = Vector2(0, 44)
UISoundService.set_button_sound(sound_toggle, "none")
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
content.add_child(sound_toggle)
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_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 = (
"Leave 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 _exit_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
var was_core_run := BlockSessionService.session_kind == "core"
var was_review_run := BlockSessionService.session_kind == "review"
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation":
BlockSessionService.block_started = false
if was_review_run:
show_screen("dashboard")
else:
show_screen("selection", "exam" if was_core_run else "step3")