Launch question bank directly from navigation

This commit is contained in:
hz
2026-07-18 14:57:33 -05:00
parent 33e461fd36
commit dca09d818f
6 changed files with 131 additions and 14 deletions
+4 -3
View File
@@ -335,7 +335,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
container.add_child(HSeparator.new())
_add_nav_button(container, "home", "Home", "house", true)
_add_nav_button(container, "practice", "Practice", "pencil", true)
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
_add_nav_button(container, "exams", "Exams", "clipboard-list", true)
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
_add_nav_button(container, "performance", "Performance", "chart-no-axes-column-increasing", true)
@@ -379,7 +379,7 @@ func _add_nav_button(container: VBoxContainer, key: String, label_text: String,
func _on_navigation_pressed(key: String) -> void:
match key:
"home": show_screen("selection", "exam")
"practice": show_screen("selection", "step3")
"question_bank": show_screen("selection", "question_bank")
"exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub")
"performance": show_screen("profile")
@@ -780,11 +780,12 @@ func _exit_run() -> void:
var ccs_player = screens.get("ccs_player")
if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case()
return
var was_core_run := BlockSessionService.session_kind == "core"
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation":
BlockSessionService.block_started = false
show_screen("selection", "step3")
show_screen("selection", "exam" if was_core_run else "step3")
func _open_profile_from_menu() -> void:
+4 -2
View File
@@ -4,9 +4,11 @@ extends RefCounted
static func key_for_screen(screen_name: String, payload: Variant = null) -> String:
var key := "home"
if screen_name == "selection":
key = "practice" if str(payload) == "step3" else "home"
key = "question_bank" if str(payload) == "question_bank" else "home"
elif screen_name in ["map", "setup", "review"]:
key = "study_plan" if screen_name == "map" else "practice"
key = "study_plan" if screen_name == "map" else "question_bank"
elif screen_name == "player":
key = "question_bank"
elif screen_name.begins_with("simulation_"):
key = "exams"
elif screen_name.begins_with("ccs_"):
+4 -3
View File
@@ -975,7 +975,7 @@ func _load_question() -> void:
var question := BlockSessionService.get_current_question()
if question.is_empty():
main.show_screen("selection", "step3")
main.show_screen("selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return
progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind == "core"
@@ -1603,16 +1603,17 @@ func _advance_or_finish() -> void:
func _finish() -> void:
if BlockSessionService.session_kind == "core":
BlockSessionService.finish_block()
main.show_screen("selection", "step3")
main.show_screen("selection", "exam")
return
var results := BlockSessionService.finish_block()
main.show_screen("review", results)
func _exit_run() -> void:
var was_core_run := BlockSessionService.session_kind == "core"
BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block()
main.show_screen("selection", "step3")
main.show_screen("selection", "exam" if was_core_run else "step3")
func _update_timer_bar() -> void:
+108 -4
View File
@@ -8,6 +8,7 @@ signal stage_changed(stage_name: String)
var main: Control
var exam_view: VBoxContainer
var mode_view: VBoxContainer
var question_bank_launch_view: VBoxContainer
var stage_scroll: ScrollContainer
var stage_content: VBoxContainer
var exam_grid: VBoxContainer
@@ -17,6 +18,9 @@ var question_bank_button: Button
var bank_status_label: Label
var bank_error_label: Label
var retry_button: Button
var launch_status_label: Label
var launch_error_label: Label
var launch_retry_button: Button
var ccs_button: Button
var ccs_status_label: Label
var pending_ccs_launch := false
@@ -51,12 +55,16 @@ func _ready() -> void:
_build_exam_view()
_build_mode_view()
_build_question_bank_launch_view()
_show_stage("exam")
func on_show(payload: Variant = null) -> void:
if str(payload) == "step3":
_show_stage("step3")
elif str(payload) == "question_bank":
_show_stage("question_bank")
call_deferred("_select_question_bank")
elif str(payload) == "exam":
_show_stage("exam")
_refresh_bank_state()
@@ -202,6 +210,84 @@ func _build_mode_view() -> void:
ccs_status_label = ccs_card.status
ccs_button.pressed.connect(_select_ccs)
func _build_question_bank_launch_view() -> void:
question_bank_launch_view = VBoxContainer.new()
question_bank_launch_view.name = "QuestionBankLaunch"
question_bank_launch_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
question_bank_launch_view.size_flags_vertical = Control.SIZE_EXPAND_FILL
question_bank_launch_view.alignment = BoxContainer.ALIGNMENT_CENTER
stage_content.add_child(question_bank_launch_view)
var center := CenterContainer.new()
center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
center.size_flags_vertical = Control.SIZE_EXPAND_FILL
question_bank_launch_view.add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(560, 300)
panel.set_meta("medmind_wide_minimum", Vector2(560, 300))
panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.TEAL))
panel.set_meta("medmind_role", "question_bank_launch")
themed_controls.append(panel)
responsive_cards.append(panel)
center.add_child(panel)
var content := VBoxContainer.new()
content.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_theme_constant_override("separation", 18)
panel.add_child(content)
var icon := TextureRect.new()
icon.texture = preload("res://assets/icons/lucide/clipboard-list.svg")
icon.custom_minimum_size = Vector2(54, 54)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = Brand.TEAL if UIThemeService.is_dark_mode() else Brand.ROYAL_BLUE
icon.set_meta("medmind_role", "question_bank_launch_icon")
themed_controls.append(icon)
content.add_child(icon)
var title := Label.new()
title.text = "Preparing your question bank"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 27)
content.add_child(title)
launch_status_label = Label.new()
launch_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
launch_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
launch_status_label.add_theme_font_override("font", Brand.font)
launch_status_label.add_theme_font_size_override("font_size", 16)
launch_status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
launch_status_label.set_meta("medmind_role", "muted")
themed_controls.append(launch_status_label)
content.add_child(launch_status_label)
launch_error_label = Label.new()
launch_error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
launch_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
launch_error_label.add_theme_font_override("font", Brand.font)
launch_error_label.add_theme_font_size_override("font_size", 15)
launch_error_label.add_theme_color_override("font_color", Style.RED)
content.add_child(launch_error_label)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_CENTER
actions.add_theme_constant_override("separation", 12)
content.add_child(actions)
launch_retry_button = Button.new()
launch_retry_button.text = "Retry"
launch_retry_button.custom_minimum_size = Vector2(120, 44)
launch_retry_button.pressed.connect(_retry_question_bank)
actions.add_child(launch_retry_button)
var cancel := Button.new()
cancel.text = "Back to Home"
cancel.custom_minimum_size = Vector2(150, 44)
cancel.pressed.connect(func(): main.show_screen("selection", "exam"))
actions.add_child(cancel)
func _add_mode_card(
title_text: String,
subtitle_text: String,
@@ -527,6 +613,8 @@ func refresh_theme_controls() -> void:
match role:
"mode_card": control.add_theme_stylebox_override("panel", _mode_card_box(enabled, accent))
"exam_card": control.add_theme_stylebox_override("panel", _exam_card_box(enabled))
"question_bank_launch": control.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.TEAL))
"question_bank_launch_icon": control.modulate = Brand.TEAL if dark else Brand.ROYAL_BLUE
"icon_circle": control.add_theme_stylebox_override("panel", _circle_box(visual_accent, 36))
"arrow_circle": control.add_theme_stylebox_override("panel", _circle_box(visual_accent, 24, enabled))
"exam_icon_circle": control.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE if enabled else Brand.PURPLE, 39))
@@ -542,7 +630,7 @@ func refresh_theme_controls() -> void:
"arrow_icon": control.modulate = Color.WHITE if dark else Brand.ROYAL_BLUE
"exam_arrow": control.modulate = Brand.TEAL if dark and enabled else Brand.ROYAL_BLUE
"back_bar": _style_back_bar(control as Button)
for node in [exam_view, mode_view]:
for node in [exam_view, mode_view, question_bank_launch_view]:
if node != null:
for child in node.find_children("*", "Label", true, false):
var label := child as Label
@@ -558,11 +646,15 @@ func _show_stage(stage_name: String) -> void:
exam_view.visible = stage_name == "exam"
if mode_view != null:
mode_view.visible = stage_name == "step3"
if question_bank_launch_view != null:
question_bank_launch_view.visible = stage_name == "question_bank"
_refresh_grid_columns()
_refresh_bank_state()
func _select_question_bank() -> void:
if pending_question_bank_launch:
return
pending_question_bank_launch = true
var excluded_ids: Array = ProfileService.get_profile().get("correct_question_ids", []).duplicate()
QuestionBankService.load_next_random_question(excluded_ids)
@@ -604,13 +696,16 @@ func _retry_question_bank() -> void:
func _on_questions_changed() -> void:
_refresh_bank_state()
if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready":
if pending_question_bank_launch and visible and current_stage in ["step3", "question_bank"] and QuestionBankService.load_state == "ready":
pending_question_bank_launch = false
call_deferred("_start_question_bank")
func _on_questions_load_failed(_message: String) -> void:
pending_question_bank_launch = false
# Keep the launch intent alive while the service performs its automatic
# backoff retry. A terminal failure still waits for an explicit retry.
if QuestionBankService.load_state != "retrying":
pending_question_bank_launch = false
_refresh_bank_state()
@@ -621,8 +716,12 @@ func _refresh_bank_state() -> void:
question_bank_button.disabled = pending_question_bank_launch
retry_button.visible = state == "failed" or state == "retrying"
retry_button.disabled = state == "retrying"
launch_retry_button.visible = state == "failed" or state == "retrying"
launch_retry_button.disabled = state == "retrying"
bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else ""
bank_error_label.visible = not bank_error_label.text.is_empty()
launch_error_label.text = bank_error_label.text
launch_error_label.visible = not launch_error_label.text.is_empty()
if pending_question_bank_launch or state == "loading":
bank_status_label.text = "Preparing your question bank..."
elif state == "retrying":
@@ -634,16 +733,21 @@ func _refresh_bank_state() -> void:
else:
bank_status_label.text = ""
bank_status_label.visible = not bank_status_label.text.is_empty()
launch_status_label.text = bank_status_label.text
launch_status_label.visible = not launch_status_label.text.is_empty()
func _start_question_bank() -> void:
if not visible or current_stage != "step3" or not QuestionBankService.has_questions():
if not visible or current_stage not in ["step3", "question_bank"] or not QuestionBankService.has_questions():
return
if BlockSessionService.start_core_run():
UISoundService.play_sound("question")
main.show_screen("player")
else:
bank_error_label.text = BlockSessionService.core_status_message
bank_error_label.visible = true
launch_error_label.text = BlockSessionService.core_status_message
launch_error_label.visible = true
func _refresh_grid_columns() -> void:
+4 -2
View File
@@ -7,8 +7,10 @@ var failures := 0
func _initialize() -> void:
_assert(NavigationRoutes.key_for_screen("selection", "exam") == "home", "exam selection should activate Home")
_assert(NavigationRoutes.key_for_screen("selection", "step3") == "practice", "Step 3 modes should activate Practice")
_assert(NavigationRoutes.key_for_screen("setup") == "practice", "block setup should activate Practice")
_assert(NavigationRoutes.key_for_screen("selection", "step3") == "home", "Step 3 mode selection should remain part of Home")
_assert(NavigationRoutes.key_for_screen("selection", "question_bank") == "question_bank", "direct question bank launch should activate Question Bank")
_assert(NavigationRoutes.key_for_screen("setup") == "question_bank", "block setup should activate Question Bank")
_assert(NavigationRoutes.key_for_screen("player") == "question_bank", "question player should map to Question Bank")
_assert(NavigationRoutes.key_for_screen("simulation_results") == "exams", "simulation results should activate Exams")
_assert(NavigationRoutes.key_for_screen("ccs_review") == "ccs", "CCS review should activate CCS Cases")
_assert(NavigationRoutes.key_for_screen("profile") == "performance", "profile should activate Performance")
+7
View File
@@ -32,6 +32,10 @@ func _run() -> void:
_assert(dynamic_icon != null, "dynamic questions should expose its mode icon")
if dynamic_icon != null:
_assert(dynamic_icon.texture.resource_path == "res://assets/icons/lucide/activity.svg", "dynamic questions should use a web-safe vector icon")
selector._show_stage("question_bank")
await process_frame
_assert(selector.question_bank_launch_view.visible and not selector.mode_view.visible, "direct question bank navigation should show only the focused launch state")
_assert(selector.launch_retry_button != null and selector.launch_error_label != null, "question bank launch should retain retry and error controls")
var main_screen = load("res://scripts/ui/main.gd").new()
main_screen.size = host.size
@@ -43,6 +47,9 @@ func _run() -> void:
_assert(main_screen.theme_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "the theme toggle icon should remain centered")
_assert(main_screen.header_panel.find_child("HeaderStats", true, false) == null, "the application header should not display rating or role labels")
_assert(not main_screen.account_chevron_button.visible, "nonessential account controls should collapse on phone-width headers")
var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button
_assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank")
_assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item")
host.queue_free()
await process_frame