Implement responsive profile and account data controls

This commit is contained in:
hz
2026-07-19 13:39:15 -05:00
parent 7508d4fe1d
commit dacf2b89b2
22 changed files with 1099 additions and 290 deletions
-20
View File
@@ -21,7 +21,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16)
_build_header()
_build_filters()
status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font)
@@ -58,25 +57,6 @@ func _ready() -> void:
UIThemeService.theme_changed.connect(func(_dark: bool): _render())
func _build_header() -> void:
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Bookmarks"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
var subtitle := Label.new()
subtitle.text = "Review saved questions without changing your rating or progression."
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
add_child(subtitle)
func _build_filters() -> void:
var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10)
+7 -5
View File
@@ -21,15 +21,17 @@ var weekly_summary: VBoxContainer
var metric_values: Dictionary = {}
var metric_details: Dictionary = {}
var pending_review_launch := false
var review_queue_service: Variant
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
review_queue_service = get_node("/root/ReviewQueueService")
DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error)
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
ReviewQueueService.question_failed.connect(_on_review_question_failed)
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
review_queue_service.question_loaded.connect(_on_review_question_loaded)
review_queue_service.question_failed.connect(_on_review_question_failed)
review_queue_service.pass_completed.connect(_on_review_pass_completed)
UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive)
_build()
@@ -225,7 +227,7 @@ func _render_focus(systems: Array) -> void:
progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress)
var practice := _button("Practice this system", "chevron-right")
var practice := _button("Practice this system")
practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
focus_content.add_child(practice)
@@ -333,7 +335,7 @@ func _start_review_queue() -> void:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
ReviewQueueService.start_pass()
review_queue_service.start_pass()
_render_review(DashboardService.data)
+11 -5
View File
@@ -471,11 +471,7 @@ func _apply_theme() -> void:
else:
label.add_theme_color_override("font_color", Brand.text_color(dark))
for input in fields:
input.add_theme_color_override("font_color", Brand.text_color(dark))
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
_style_auth_field(input, dark)
for button in find_children("*", "Button", true, false):
var typed := button as Button
typed.add_theme_font_override("font", Brand.font)
@@ -503,6 +499,16 @@ func _apply_theme() -> void:
brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark))
func _style_auth_field(input: LineEdit, dark: bool) -> void:
input.add_theme_color_override("font_color", Brand.text_color(dark))
input.add_theme_color_override("font_uneditable_color", Brand.text_color(dark))
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
input.add_theme_stylebox_override("read_only", Brand.field_box(dark))
func _on_theme_changed(_is_dark: bool) -> void:
_apply_theme()
+1 -1
View File
@@ -381,12 +381,12 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
_add_nav_button(container, "performance", "Profile", "user-round", 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, "performance", "Profile", "user-round", true)
_add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true)
-12
View File
@@ -1,6 +1,5 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
var main
@@ -11,17 +10,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16)
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Notes"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
notes_editor = NotesEditor.new()
add_child(notes_editor)
+522 -219
View File
@@ -1,266 +1,569 @@
extends VBoxContainer
extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main: Control
var profile_text: RichTextLabel
var reset_qbank_button: Button
var reset_qbank_pending := false
var scroll: ScrollContainer
var page: VBoxContainer
var columns: GridContainer
var profile_name: Label
var profile_identity: Label
var profile_meta: Label
var identity_values: Dictionary = {}
var metric_values: Dictionary = {}
var systems_list: VBoxContainer
var status_label: Label
var outcome_score: SpinBox
var outcome_date: LineEdit
var outcome_consent: CheckBox
var outcome_status: Label
var outcome_delete_button: Button
var outcome_grid: GridContainer
var ccs_rating_label: Label
var outcome_delete: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var title := Label.new()
title.text = "Progress Profile"
Style.set_label(title, 34, Style.CYAN)
add_child(title)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(scroll)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 14)
scroll.add_child(content)
ccs_rating_label = Label.new()
Style.set_label(ccs_rating_label, 20, Style.CYAN)
content.add_child(ccs_rating_label)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
panel.custom_minimum_size.y = 300
content.add_child(panel)
profile_text = RichTextLabel.new()
profile_text.bbcode_enabled = true
profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_child(profile_text)
_build_outcome_panel(content)
var row := HBoxContainer.new()
var back := Button.new()
back.text = "Back"
back.pressed.connect(func(): main.show_screen("selection", "step3"))
row.add_child(back)
var reset := Button.new()
reset.text = "Reset Progress"
Style.style_chip(reset, Style.RED)
reset.pressed.connect(func():
ProfileService.reset_profile()
on_show()
)
row.add_child(reset)
var simulations := Button.new()
simulations.text = "Simulation History"
Style.style_chip(simulations, Style.CYAN)
simulations.pressed.connect(func(): main.show_screen("simulation_history"))
row.add_child(simulations)
var ccs_history := Button.new()
ccs_history.text = "CCS History"
Style.style_chip(ccs_history, Style.CYAN)
ccs_history.pressed.connect(func(): main.show_screen("ccs_history"))
row.add_child(ccs_history)
reset_qbank_button = Button.new()
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
reset_qbank_button.pressed.connect(_on_reset_qbank_pressed)
row.add_child(reset_qbank_button)
add_child(row)
if not ExamSimulationService.outcome_changed.is_connected(_refresh_outcome):
ExamSimulationService.outcome_changed.connect(_refresh_outcome)
if not ExamSimulationService.request_failed.is_connected(_on_outcome_request_failed):
ExamSimulationService.request_failed.connect(_on_outcome_request_failed)
if not CCSService.rating_changed.is_connected(_refresh_ccs_rating):
CCSService.rating_changed.connect(_refresh_ccs_rating)
resized.connect(_refresh_outcome_layout)
_refresh_outcome_layout()
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
AccountProfileService.profile_changed.connect(_render)
AccountProfileService.request_failed.connect(_show_error)
AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved."))
AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path))
DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error)
ExamSimulationService.outcome_changed.connect(_render_outcome)
ExamSimulationService.request_failed.connect(_show_error)
UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive)
_build()
func on_show(_payload: Variant = null) -> void:
_reset_qbank_confirmation()
_refresh_ccs_rating()
var profile := ProfileService.get_profile()
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
QuestionBankService.overall_elo,
QuestionBankService.rated_question_count,
profile["current_streak"],
profile["best_streak"],
profile.get("daily_questions_answered", 0),
profile.get("daily_goal_questions", 10)
]
if QuestionBankService.category_elos.is_empty():
text += "No rated categories yet.\n"
else:
var categories := QuestionBankService.category_elos.keys()
categories.sort()
for category in categories:
text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])]
text += "\n[b]System Accuracy[/b]\n"
if profile["system_stats"].is_empty():
text += "No completed questions yet.\n"
else:
for system_name in profile["system_stats"].keys():
var stats: Dictionary = profile["system_stats"][system_name]
var seen := int(stats.get("seen", 0))
var correct := int(stats.get("correct", 0))
var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0))
text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy]
text += "\n[b]Recent Blocks[/b]\n"
for block in profile["block_history"]:
text += "- %s: %s %d/%d\n" % [
block.get("timestamp", ""),
block.get("system", ""),
block.get("correct", 0),
block.get("total", 0)
]
profile_text.text = text
_render()
AccountProfileService.refresh()
DashboardService.refresh()
CCSService.load_rating()
outcome_status.text = "Loading official outcome..."
ExamSimulationService.load_outcome()
func _refresh_ccs_rating() -> void:
ccs_rating_label.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)]
func _build() -> void:
for child in get_children():
remove_child(child)
child.free()
identity_values.clear()
metric_values.clear()
scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
page = VBoxContainer.new()
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 14)
scroll.add_child(page)
status_label = _label("", 13, Brand.muted_color(_dark()))
status_label.visible = false
page.add_child(status_label)
columns = GridContainer.new()
columns.columns = 2
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
columns.add_theme_constant_override("h_separation", 14)
columns.add_theme_constant_override("v_separation", 14)
page.add_child(columns)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.add_theme_constant_override("separation", 14)
columns.add_child(left)
left.add_child(_profile_summary_card())
left.add_child(_study_identity_card())
left.add_child(_performance_card())
left.add_child(_systems_card())
var right := VBoxContainer.new()
right.custom_minimum_size.x = 410
right.size_flags_horizontal = Control.SIZE_EXPAND_FILL
right.add_theme_constant_override("separation", 14)
columns.add_child(right)
right.add_child(_outcome_card())
right.add_child(_account_actions_card())
_update_responsive()
_render()
func _build_outcome_panel(parent: VBoxContainer) -> void:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
parent.add_child(panel)
var section := VBoxContainer.new()
section.add_theme_constant_override("separation", 10)
panel.add_child(section)
var heading := Label.new()
heading.text = "Official Step 3 Outcome"
Style.set_label(heading, 22, Style.TEXT)
section.add_child(heading)
var note := Label.new()
note.text = "After receiving your official result, you may voluntarily submit it. You can edit or delete it at any time; calibration uses de-identified aggregates."
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(note, 15, Style.MUTED)
section.add_child(note)
outcome_grid = GridContainer.new()
outcome_grid.add_theme_constant_override("h_separation", 10)
outcome_grid.add_theme_constant_override("v_separation", 10)
section.add_child(outcome_grid)
func _profile_summary_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 14)
body.add_child(_section_header("Profile Summary", "Your MedMind account"))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 18)
var avatar := PanelContainer.new()
avatar.custom_minimum_size = Vector2(74, 74)
avatar.add_theme_stylebox_override("panel", Brand.icon_circle_box(_dark(), Brand.PURPLE, 37, true))
var avatar_icon := TextureRect.new()
avatar_icon.texture = load("res://assets/icons/lucide/user-round.svg")
avatar_icon.custom_minimum_size = Vector2(34, 34)
avatar_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
avatar_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
avatar.add_child(avatar_icon)
row.add_child(avatar)
var copy := VBoxContainer.new()
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
profile_name = _label("Loading profile...", 22, Brand.text_color(_dark()), true)
copy.add_child(profile_name)
profile_identity = _label("", 14, Brand.muted_color(_dark()))
copy.add_child(profile_identity)
profile_meta = _label("", 13, Brand.muted_color(_dark()))
copy.add_child(profile_meta)
row.add_child(copy)
var edit := _button("Edit profile", "pencil", false)
edit.size_flags_vertical = Control.SIZE_SHRINK_CENTER
edit.pressed.connect(_open_profile_editor)
row.add_child(edit)
body.add_child(row)
return _card(body)
func _study_identity_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
var header := _header_with_action("Study Identity", "Your preparation plan", "Edit", _open_study_editor)
body.add_child(header)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 20)
grid.add_theme_constant_override("v_separation", 12)
for item in [
["exam", "Target exam date"], ["stage", "Current stage"],
["goal", "Weekly study goal"], ["mode", "Preferred study mode"]
]:
var box := VBoxContainer.new()
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
box.add_child(_label(item[1], 12, Brand.muted_color(_dark())))
var value := _label("--", 15, Brand.text_color(_dark()), true)
identity_values[item[0]] = value
box.add_child(value)
grid.add_child(box)
body.add_child(grid)
return _card(body)
func _performance_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
body.add_child(_section_header("Performance Summary", "Your cumulative MedMind activity"))
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 10)
for item in [
["qbank", "Question Bank rating", Brand.ROYAL_BLUE], ["ccs", "CCS rating", Brand.PURPLE],
["accuracy", "First-attempt accuracy", Brand.TEAL], ["questions", "Questions completed", Brand.ROYAL_BLUE],
["cases", "CCS cases completed", Brand.PURPLE], ["streak", "Current streak", Brand.AMBER]
]:
var metric := VBoxContainer.new()
metric.size_flags_horizontal = Control.SIZE_EXPAND_FILL
metric.add_child(_label(item[1], 11, Brand.muted_color(_dark())))
var value := _label("--", 23, item[2], true)
metric_values[item[0]] = value
metric.add_child(value)
grid.add_child(metric)
body.add_child(grid)
return _card(body)
func _systems_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
var header := _header_with_action("System Performance", "Accuracy by Step 3 system", "View analytics", func(): main.show_screen("dashboard"))
body.add_child(header)
systems_list = VBoxContainer.new()
systems_list.add_theme_constant_override("separation", 8)
body.add_child(systems_list)
return _card(body)
func _outcome_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
body.add_child(_section_header("Official Step 3 Outcome", "Optionally help calibrate future predictions"))
outcome_score = SpinBox.new()
outcome_score.min_value = 1
outcome_score.max_value = 300
outcome_score.value = 200
outcome_score.custom_minimum_size = Vector2(120, 44)
outcome_score.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_score)
outcome_score.custom_minimum_size.y = 44
body.add_child(_field("Official score", outcome_score))
outcome_date = LineEdit.new()
outcome_date.placeholder_text = "Exam date: YYYY-MM-DD"
outcome_date.custom_minimum_size = Vector2(220, 44)
outcome_date.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_date)
outcome_date.placeholder_text = "YYYY-MM-DD"
_style_field(outcome_date)
body.add_child(_field("Exam date", outcome_date))
outcome_consent = CheckBox.new()
outcome_consent.text = "I consent to calibration use"
outcome_consent.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_consent)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
outcome_grid.add_child(actions)
var save_button := Button.new()
save_button.text = "Save outcome"
Style.style_chip(save_button, Style.CYAN)
save_button.pressed.connect(_save_outcome)
actions.add_child(save_button)
outcome_delete_button = Button.new()
outcome_delete_button.text = "Delete outcome"
Style.style_chip(outcome_delete_button, Style.RED)
outcome_delete_button.pressed.connect(_delete_outcome)
actions.add_child(outcome_delete_button)
outcome_status = Label.new()
outcome_consent.text = "I consent to de-identified calibration use"
body.add_child(outcome_consent)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var save := _button("Save outcome", "circle-check", true)
save.size_flags_horizontal = Control.SIZE_EXPAND_FILL
save.pressed.connect(_save_outcome)
row.add_child(save)
outcome_delete = _button("Delete", "x", false)
outcome_delete.pressed.connect(ExamSimulationService.delete_outcome)
row.add_child(outcome_delete)
body.add_child(row)
outcome_status = _label("No official outcome is stored.", 12, Brand.muted_color(_dark()))
outcome_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(outcome_status, 15, Style.MUTED)
section.add_child(outcome_status)
body.add_child(outcome_status)
return _card(body)
func _refresh_outcome_layout() -> void:
if outcome_grid != null:
outcome_grid.columns = 1 if size.x < 900.0 else 4
func _account_actions_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 9)
body.add_child(_section_header("Account Actions", "Security and data controls"))
var password := _action_button("Change password", "settings")
password.pressed.connect(_open_password_dialog)
body.add_child(password)
var export := _action_button("Export my data", "copy")
export.pressed.connect(func(): AccountProfileService.export_archive())
body.add_child(export)
var delete := _action_button("Delete account permanently", "x", Brand.MAGENTA)
delete.pressed.connect(_open_delete_dialog)
body.add_child(delete)
return _card(body)
func _render() -> void:
if profile_name == null:
return
var account: Dictionary = AccountProfileService.account
var prefs: Dictionary = AccountProfileService.preferences
var username := str(account.get("username", AuthState.username))
var display_name := str(account.get("displayName", "")).strip_edges()
profile_name.text = username if display_name.is_empty() else display_name
var email := str(account.get("email", "")).strip_edges()
profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email]
var role := str(account.get("role", AuthState.role)).capitalize()
var created := _date_only(str(account.get("createdUtc", "")))
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
(identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
(identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
(identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
(identity_values["mode"] as Label).text = _friendly(str(prefs.get("preferredStudyMode", "mixed")))
var data: Dictionary = DashboardService.data
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
_set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000)))))
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)))
_set_metric("questions", str(int(data.get("questionsCompleted", 0))))
_set_metric("cases", str(int(data.get("ccsCasesCompleted", 0))))
_set_metric("streak", "%d days" % int(data.get("currentStreak", 0)))
_render_systems(data.get("systems", []))
_render_outcome()
func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void:
if systems_list == null:
return
_clear(systems_list)
if systems.is_empty():
systems_list.add_child(_label("Complete questions to build your system breakdown.", 13, Brand.muted_color(_dark())))
return
var sorted := systems.duplicate(true)
sorted.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0)))
for item in sorted.slice(0, mini(8, sorted.size())):
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var title := _label(str(item.get("system", "General")), 13, Brand.text_color(_dark()))
title.custom_minimum_size.x = 210
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(title)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("accuracy", 0))
bar.custom_minimum_size = Vector2(160, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, Brand.ROYAL_BLUE)
row.add_child(bar)
var detail := _label("%d%% %d / %d" % [int(item.get("accuracy", 0)), int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(_dark()))
detail.custom_minimum_size.x = 110
row.add_child(detail)
systems_list.add_child(row)
func _render_outcome() -> void:
if outcome_status == null:
return
var outcome: Dictionary = ExamSimulationService.outcome
if outcome.is_empty():
outcome_status.text = "No official outcome is stored."
outcome_delete.visible = false
return
outcome_score.value = int(outcome.get("officialScore", 200))
outcome_date.text = _date_only(str(outcome.get("examDate", "")))
outcome_consent.button_pressed = true
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
outcome_delete.visible = true
func _open_profile_editor() -> void:
var dialog := _dialog("Edit profile")
var display := LineEdit.new()
display.placeholder_text = "Display name"
display.text = str(AccountProfileService.account.get("displayName", ""))
_style_field(display)
dialog.add_field(display)
var email := LineEdit.new()
email.placeholder_text = "Email address"
email.text = str(AccountProfileService.account.get("email", ""))
_style_field(email)
dialog.add_field(email)
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 260))
func _open_study_editor() -> void:
var prefs: Dictionary = AccountProfileService.preferences
var dialog := _dialog("Edit study identity")
var exam := LineEdit.new()
exam.placeholder_text = "Target exam date: YYYY-MM-DD"
exam.text = _date_only(str(prefs.get("targetExamDate", "")))
_style_field(exam)
dialog.add_field(exam)
var stage := OptionButton.new()
_add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], str(prefs.get("preparationStage", "pre_dedicated")))
dialog.add_field(stage)
var goal := OptionButton.new()
for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours)
_select_id(goal, int(prefs.get("weeklyGoalHours", 10)))
dialog.add_field(goal)
var mode := OptionButton.new()
_add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], str(prefs.get("preferredStudyMode", "mixed")))
dialog.add_field(mode)
dialog.confirmed.connect(func():
AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected))))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 390))
func _open_password_dialog() -> void:
var dialog := _dialog("Change password")
var current := _password_field("Current password")
var replacement := _password_field("New password (8+ characters)")
var confirmation := _password_field("Confirm new password")
dialog.add_field(current)
dialog.add_field(replacement)
dialog.add_field(confirmation)
dialog.confirmed.connect(func():
if replacement.text != confirmation.text or replacement.text.length() < 8:
_show_error("New passwords must match and contain at least 8 characters.")
return
AccountProfileService.change_password(current.text, replacement.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 330))
func _open_delete_dialog() -> void:
var dialog := _dialog("Permanently delete account")
var warning := _label("This permanently removes your account and all MedMind study data. This cannot be undone.", 13, Brand.MAGENTA)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
dialog.add_field(warning)
var password := _password_field("Current password")
dialog.add_field(password)
var confirmation := LineEdit.new()
confirmation.placeholder_text = "Type DELETE"
_style_field(confirmation)
dialog.add_field(confirmation)
dialog.confirmed.connect(func(): AccountProfileService.delete_account(password.text, confirmation.text))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 330))
func _save_outcome() -> void:
var error := validate_outcome(int(outcome_score.value), outcome_date.text, outcome_consent.button_pressed)
if not error.is_empty():
_show_error(error)
return
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
static func validate_outcome(score: int, exam_date: String, consent: bool) -> String:
if score < 1 or score > 300:
return "Enter an official score from 1 through 300."
if RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}$").search(exam_date.strip_edges()) == null:
return "Enter the exam date as YYYY-MM-DD."
if not consent:
return "Consent is required before saving an outcome."
var date_pattern := RegEx.new()
date_pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
if date_pattern.search(exam_date) == null:
return "Enter the exam date as YYYY-MM-DD."
return ""
func _save_outcome() -> void:
var validation_error := validate_outcome(int(outcome_score.value), outcome_date.text.strip_edges(), outcome_consent.button_pressed)
if not validation_error.is_empty():
outcome_status.text = validation_error
return
outcome_status.text = "Saving..."
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
func _update_responsive() -> void:
if columns != null:
columns.columns = 1 if size.x < 1050.0 else 2
func _delete_outcome() -> void:
outcome_status.text = "Deleting..."
ExamSimulationService.delete_outcome()
func _rebuild(_is_dark: bool) -> void:
_build()
func _refresh_outcome() -> void:
if ExamSimulationService.outcome.is_empty():
outcome_score.value = 200
outcome_date.text = ""
outcome_consent.button_pressed = false
outcome_delete_button.visible = false
outcome_status.text = "No official outcome is stored."
return
outcome_score.value = int(ExamSimulationService.outcome.get("officialScore", 200))
outcome_date.text = str(ExamSimulationService.outcome.get("examDate", "")).left(10)
outcome_consent.button_pressed = true
outcome_delete_button.visible = true
outcome_status.text = "Official outcome saved."
func _show_error(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
status_label.visible = true
func _on_outcome_request_failed(message: String) -> void:
if visible:
outcome_status.text = message
func _show_saved(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
status_label.visible = true
get_tree().create_timer(3.0).timeout.connect(func(): if is_instance_valid(status_label): status_label.visible = false)
func _on_reset_qbank_pressed() -> void:
if not reset_qbank_pending:
reset_qbank_pending = true
reset_qbank_button.text = "Confirm Reset QBank"
Style.style_chip(reset_qbank_button, Style.RED)
return
ProfileService.reset_qbank_memory()
if BlockSessionService.core_status_message == "All available questions cleared.":
BlockSessionService.core_status_message = ""
_reset_qbank_confirmation()
on_show()
func _card(child: Control) -> PanelContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var box := Brand.content_card_box(_dark(), false)
box.content_margin_left = 20
box.content_margin_right = 20
box.content_margin_top = 18
box.content_margin_bottom = 18
panel.add_theme_stylebox_override("panel", box)
panel.add_child(child)
return panel
func _reset_qbank_confirmation() -> void:
reset_qbank_pending = false
if reset_qbank_button != null:
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
func _section_header(title: String, subtitle: String) -> VBoxContainer:
var box := VBoxContainer.new()
box.add_child(_label(title, 18, Brand.text_color(_dark()), true))
box.add_child(_label(subtitle, 12, Brand.muted_color(_dark())))
return box
func _header_with_action(title: String, subtitle: String, action: String, callback: Callable) -> HBoxContainer:
var row := HBoxContainer.new()
var heading := _section_header(title, subtitle)
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(heading)
var button := _button(action, "pencil", false)
button.pressed.connect(callback)
row.add_child(button)
return row
func _button(text: String, icon_name: String = "", primary: bool = false) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(0, 42)
button.add_theme_font_override("font", Brand.font)
button.add_theme_font_size_override("font_size", 13)
if not icon_name.is_empty():
button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name)
button.add_theme_constant_override("icon_max_width", 17)
if primary:
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE)
else:
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(_dark()))
return button
func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button:
var button := _button(text, icon_name, false)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
if color != Color.TRANSPARENT:
button.add_theme_color_override("font_color", color)
button.add_theme_color_override("icon_normal_color", color)
return button
func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
return label
func _field(caption: String, control: Control) -> VBoxContainer:
var box := VBoxContainer.new()
box.add_child(_label(caption, 12, Brand.muted_color(_dark())))
box.add_child(control)
return box
func _dialog(title: String) -> ProfileDialog:
var dialog := ProfileDialog.new()
dialog.title = title
dialog.ok_button_text = "Save"
dialog.add_theme_font_override("font", Brand.font)
dialog.fields.custom_minimum_size.x = 450
return dialog
func _password_field(placeholder: String) -> LineEdit:
var field := LineEdit.new()
field.placeholder_text = placeholder
field.secret = true
field.secret_character = "•"
_style_field(field)
return field
func _style_field(field: LineEdit) -> void:
field.custom_minimum_size.y = 46
field.add_theme_font_override("font", Brand.font)
field.add_theme_stylebox_override("normal", Brand.field_box(_dark()))
field.add_theme_stylebox_override("focus", Brand.field_box(_dark(), true))
func _style_progress(bar: ProgressBar, accent: Color) -> void:
var background := StyleBoxFlat.new()
background.bg_color = Color("26324a") if _dark() else Color("e2e8f0")
background.set_corner_radius_all(4)
var fill := StyleBoxFlat.new()
fill.bg_color = accent
fill.set_corner_radius_all(4)
bar.add_theme_stylebox_override("background", background)
bar.add_theme_stylebox_override("fill", fill)
func _set_metric(key: String, value: String) -> void:
if metric_values.has(key): (metric_values[key] as Label).text = value
func _clear(container: Container) -> void:
for child in container.get_children():
container.remove_child(child)
child.free()
func _date_only(value: String, fallback: String = "") -> String:
if value.is_empty() or value == "<null>": return fallback
return value.left(10)
func _friendly(value: String) -> String:
return value.replace("_", " ").capitalize()
func _dark() -> bool:
return UIThemeService.is_dark_mode()
func _add_options(control: OptionButton, options: Array, selected_value: String) -> void:
for option in options:
control.add_item(str(option[0]))
control.set_item_metadata(control.item_count - 1, option[1])
if str(option[1]) == selected_value: control.select(control.item_count - 1)
func _select_id(control: OptionButton, id: int) -> void:
for index in control.item_count:
if control.get_item_id(index) == id: control.select(index)
class ProfileDialog extends AcceptDialog:
var fields: VBoxContainer
func _init() -> void:
fields = VBoxContainer.new()
fields.add_theme_constant_override("separation", 10)
add_child(fields)
func add_field(node: Control) -> void:
fields.add_child(node)
-8
View File
@@ -7,7 +7,6 @@ const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-inc
var main: Control
var page: VBoxContainer
var title_label: Label
var active_section: PanelContainer
var active_content: VBoxContainer
var history_section: PanelContainer
@@ -51,12 +50,6 @@ func _build_ui() -> void:
page.add_theme_constant_override("separation", 18)
scroll.add_child(page)
title_label = Label.new()
title_label.text = "Exams"
Brand.apply_font(title_label)
title_label.add_theme_font_size_override("font_size", 27)
page.add_child(title_label)
active_section = PanelContainer.new()
active_section.name = "ActiveSimulationSection"
page.add_child(active_section)
@@ -161,7 +154,6 @@ func refresh_theme_controls() -> void:
if page == null:
return
var dark := UIThemeService.is_dark_mode()
title_label.add_theme_color_override("font_color", Brand.text_color(dark))
active_section.add_theme_stylebox_override("panel", _section_box(dark))
history_section.add_theme_stylebox_override("panel", _section_box(dark))
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
+26 -19
View File
@@ -148,46 +148,53 @@ static func style_chip(button: Button, color: Color) -> void:
static func refresh_tree(root: Control, was_dark: bool) -> void:
apply_app_theme(root)
_translate_control(root, DARK_PALETTE if was_dark else LIGHT_PALETTE, LIGHT_PALETTE if was_dark else DARK_PALETTE)
_translate_control(
root,
DARK_PALETTE if was_dark else LIGHT_PALETTE,
LIGHT_PALETTE if was_dark else DARK_PALETTE,
was_dark
)
static func _translate_control(control: Control, source: Dictionary, target: Dictionary) -> void:
static func _translate_control(control: Control, source: Dictionary, target: Dictionary, from_dark: bool) -> void:
if control is ColorRect:
control.color = _translate_color(control.color, source, target)
control.color = _translate_color(control.color, source, target, from_dark)
for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]:
if control.has_theme_color_override(color_name):
control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target))
control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target, from_dark))
for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]:
if control.has_theme_stylebox_override(style_name):
var box := control.get_theme_stylebox(style_name)
if box is StyleBoxFlat:
var translated := box.duplicate() as StyleBoxFlat
translated.bg_color = _translate_color(translated.bg_color, source, target)
translated.border_color = _translate_color(translated.border_color, source, target)
translated.bg_color = _translate_color(translated.bg_color, source, target, from_dark)
translated.border_color = _translate_color(translated.border_color, source, target, from_dark)
control.add_theme_stylebox_override(style_name, translated)
for child in control.get_children():
if child is Control:
_translate_control(child, source, target)
_translate_control(child, source, target, from_dark)
static func _translate_color(color: Color, source: Dictionary, target: Dictionary) -> Color:
static func _translate_color(color: Color, source: Dictionary, target: Dictionary, from_dark: bool) -> Color:
for key in source:
var candidate: Color = source[key]
if color.is_equal_approx(candidate):
var replacement: Color = target[key]
replacement.a = color.a
return replacement
for dark_color in CUSTOM_DARK_TO_LIGHT:
if color.is_equal_approx(dark_color):
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
replacement.a = color.a
return replacement
for dark_color in CUSTOM_DARK_TO_LIGHT:
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
if color.is_equal_approx(light_color):
var replacement: Color = dark_color
replacement.a = color.a
return replacement
if from_dark:
for dark_color in CUSTOM_DARK_TO_LIGHT:
if color.is_equal_approx(dark_color):
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
replacement.a = color.a
return replacement
else:
for dark_color in CUSTOM_DARK_TO_LIGHT:
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
if color.is_equal_approx(light_color):
var replacement: Color = dark_color
replacement.a = color.a
return replacement
return color