diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 35ef020..1ec544d 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -495,6 +495,7 @@ func _update_header_title(screen_name: String) -> void: "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", diff --git a/scripts/ui/simulation_results.gd b/scripts/ui/simulation_results.gd index d2a9250..f3d622f 100644 --- a/scripts/ui/simulation_results.gd +++ b/scripts/ui/simulation_results.gd @@ -1,85 +1,245 @@ extends VBoxContainer -const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") +const CORRECT_ICON := preload("res://assets/icons/lucide/circle-check.svg") +const PROBABILITY_ICON := preload("res://assets/icons/lucide/activity.svg") +const SEEN_ICON := preload("res://assets/icons/lucide/eye.svg") +const CATEGORY_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg") +const REVIEW_ICON := preload("res://assets/icons/lucide/clipboard-list.svg") +const CATEGORY_PALETTE := [Brand.ROYAL_BLUE, Brand.SUCCESS, Brand.AMBER, Brand.MAGENTA, Brand.SKY_BLUE] var main: Control -var score_label: Label -var summary_label: Label -var category_list: VBoxContainer +var content: VBoxContainer var current_simulation := {} var return_screen := "selection" -var done_button: Button func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 12) - var eyebrow := Label.new() - eyebrow.text = "STEP 3 DAY 1 SIMULATION COMPLETE" - Style.set_label(eyebrow, 14, Style.CYAN) - add_child(eyebrow) - score_label = Label.new() - Style.set_label(score_label, 36, Style.LIME) - add_child(score_label) - summary_label = Label.new() - summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(summary_label, 17, Style.TEXT) - add_child(summary_label) - + add_theme_constant_override("separation", 0) var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED add_child(scroll) - var content := VBoxContainer.new() + content = VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content.add_theme_constant_override("separation", 10) + content.add_theme_constant_override("separation", 20) scroll.add_child(content) - var category_heading := Label.new() - category_heading.text = "Performance by content area" - Style.set_label(category_heading, 22, Style.TEXT) - content.add_child(category_heading) - category_list = VBoxContainer.new() - content.add_child(category_list) - var review_button := Button.new() - review_button.text = "Review Questions" - review_button.custom_minimum_size = Vector2(240, 50) - review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_primary_button(review_button) - review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen})) - content.add_child(review_button) - - done_button = Button.new() - done_button.text = "Return to study modes" - done_button.custom_minimum_size = Vector2(240, 50) - done_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_primary_button(done_button) - done_button.pressed.connect(_return_from_results) - add_child(done_button) + UIThemeService.theme_changed.connect(func(_dark: bool): _render()) func on_show(payload: Variant = null) -> void: var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {} var simulation: Dictionary = wrapper.get("simulation", wrapper) if not wrapper.is_empty() else ExamSimulationService.simulation return_screen = str(wrapper.get("return_screen", "selection")) - done_button.text = "Return to simulation history" if return_screen == "simulation_history" else "Return to study modes" current_simulation = simulation - var score: Dictionary = simulation.get("score", {}) + _render() + + +func _render() -> void: + _clear(content) + var dark := UIThemeService.is_dark_mode() + var score: Dictionary = current_simulation.get("score", {}) + content.add_child(_build_hero_card(dark, score)) + content.add_child(_build_stats_row(dark, score)) + content.add_child(_build_disclaimer(dark, score)) + content.add_child(_build_category_card(dark, score)) + content.add_child(_build_actions(dark)) + + +func _build_hero_card(dark: bool, score: Dictionary) -> PanelContainer: var predicted := int(score.get("predictedScore", 0)) var low := int(score.get("rangeLow", 0)) var high := int(score.get("rangeHigh", 0)) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 24) + + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.add_theme_constant_override("separation", 10) + row.add_child(copy) + + copy.add_child(_label("STEP 3 DAY 1 SIMULATION COMPLETE", 13, Brand.SKY_BLUE, true)) + var headline := _label("Provisional Step 3 estimate: %d" % predicted, 26, Brand.text_color(dark), true) + headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.add_child(headline) + + var badge := _label("%d-%d Predicted score range" % [low, high], 13, Brand.text_color(dark)) + badge.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, Brand.ROYAL_BLUE)) + copy.add_child(badge) + + row.add_child(_score_medallion(dark, predicted, Brand.ROYAL_BLUE)) + return _card(dark, row, Brand.ROYAL_BLUE, true) + + +func _score_medallion(dark: bool, score: int, accent: Color) -> Control: + var wrap := Control.new() + wrap.custom_minimum_size = Vector2(120, 120) + wrap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + wrap.size_flags_vertical = Control.SIZE_SHRINK_CENTER + + var panel := PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 60, true)) + wrap.add_child(panel) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_child(center) + center.add_child(_label(str(score), 34, Brand.DARK_TEXT, true)) + return wrap + + +func _build_stats_row(dark: bool, score: Dictionary) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 16) + var correct := int(score.get("correct", 0)) + var total := int(score.get("total", 0)) var probability := int(round(float(score.get("passProbability", 0.0)) * 100.0)) + var seen := int(score.get("seenCount", 0)) + row.add_child(_stat_card(dark, CORRECT_ICON, Brand.ROYAL_BLUE, "%d / %d" % [correct, total], "Correct")) + row.add_child(_stat_card(dark, PROBABILITY_ICON, Brand.SUCCESS, "%d%%" % probability, "Heuristic probability of reaching 200+")) + row.add_child(_stat_card(dark, SEEN_ICON, Brand.SKY_BLUE, str(seen), "Previously seen questions")) + return row + + +func _stat_card(dark: bool, icon: Texture2D, accent: Color, value: String, caption: String) -> PanelContainer: + var inner := HBoxContainer.new() + inner.add_theme_constant_override("separation", 12) + + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(44, 44) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 22, true)) + var icon_rect := TextureRect.new() + icon_rect.texture = icon + icon_rect.custom_minimum_size = Vector2(20, 20) + icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_rect.modulate = Brand.DARK_TEXT + icon_panel.add_child(icon_rect) + inner.add_child(icon_panel) + + var copy := VBoxContainer.new() + copy.add_theme_constant_override("separation", 2) + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.add_child(_label(value, 20, Brand.text_color(dark), true)) + var caption_label := _label(caption, 12, Brand.muted_color(dark)) + caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.add_child(caption_label) + inner.add_child(copy) + + return _card(dark, inner) + + +func _build_disclaimer(dark: bool, score: Dictionary) -> Label: var raw_accuracy := float(score.get("rawAccuracy", 0.0)) * 100.0 var adjusted_accuracy := float(score.get("difficultyAdjustedAccuracy", 0.0)) * 100.0 - score_label.text = "Provisional Step 3 estimate: %d (%d-%d)" % [predicted, low, high] - summary_label.text = "%d/%d correct (%.1f%%) | Difficulty-adjusted accuracy: %.1f%%\nHeuristic probability of provisional score >=200: %d%% | Previously seen questions: %d\nThis transparent, unvalidated educational heuristic is not an official USMLE or NBME score and excludes CCS performance." % [ - int(score.get("correct", 0)), int(score.get("total", 0)), raw_accuracy, adjusted_accuracy, - probability, int(score.get("seenCount", 0))] - _clear(category_list) - for category in score.get("categories", {}): - var value: Dictionary = score["categories"][category] - var label := Label.new() - label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))] - Style.set_label(label, 16, Style.TEXT) - category_list.add_child(label) + var label := _label( + "%.1f%% raw accuracy ยท %.1f%% difficulty-adjusted accuracy\nThis transparent, unvalidated educational heuristic is not an official USMLE or NBME score and excludes CCS performance." % [raw_accuracy, adjusted_accuracy], + 12, Brand.muted_color(dark)) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func _build_category_card(dark: bool, score: Dictionary) -> PanelContainer: + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 14) + + var heading := HBoxContainer.new() + heading.add_theme_constant_override("separation", 8) + var heading_icon := TextureRect.new() + heading_icon.texture = CATEGORY_ICON + heading_icon.custom_minimum_size = Vector2(20, 20) + heading_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + heading_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + heading_icon.modulate = Brand.ROYAL_BLUE + heading.add_child(heading_icon) + heading.add_child(_label("Performance by content area", 18, Brand.text_color(dark), true)) + column.add_child(heading) + + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 32) + grid.add_theme_constant_override("v_separation", 14) + var categories: Dictionary = score.get("categories", {}) + var index := 0 + for category_name in categories: + var value: Dictionary = categories[category_name] + grid.add_child(_category_row(dark, str(category_name), value, index)) + index += 1 + if categories.is_empty(): + column.add_child(_label("No content-area breakdown is available for this simulation.", 13, Brand.muted_color(dark))) + else: + column.add_child(grid) + + return _card(dark, column) + + +func _category_row(dark: bool, category_name: String, value: Dictionary, index: int) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var accent: Color = CATEGORY_PALETTE[index % CATEGORY_PALETTE.size()] + + var dot := PanelContainer.new() + dot.custom_minimum_size = Vector2(10, 10) + dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER + dot.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 5, true)) + row.add_child(dot) + + var correct := int(value.get("correct", 0)) + var total := int(value.get("total", 0)) + var percent := int(round(100.0 * float(correct) / float(total))) if total > 0 else 0 + + var title := _label(category_name, 13, Brand.text_color(dark)) + title.custom_minimum_size.x = 200 + title.clip_text = true + title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + row.add_child(title) + + var bar := ProgressBar.new() + bar.show_percentage = false + bar.min_value = 0.0 + bar.max_value = 100.0 + bar.value = percent + bar.custom_minimum_size = Vector2(90, 7) + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _style_progress(bar, accent, dark) + row.add_child(bar) + + var detail := _label("%d%% %d/%d" % [percent, correct, total], 12, accent if dark else accent.darkened(0.15)) + detail.custom_minimum_size.x = 76 + detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + row.add_child(detail) + return row + + +func _build_actions(dark: bool) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + + var review_button := Button.new() + review_button.text = "Review Questions" + review_button.icon = REVIEW_ICON + review_button.custom_minimum_size = Vector2(220, 52) + review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _style_gradient_button(review_button) + review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen})) + row.add_child(review_button) + + var back_button := Button.new() + back_button.text = "Return to simulation history" if return_screen == "simulation_history" else "Return to study modes" + back_button.custom_minimum_size = Vector2(220, 52) + back_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _style_outline_button(back_button, dark) + back_button.pressed.connect(_return_from_results) + row.add_child(back_button) + return row func _return_from_results() -> void: @@ -89,6 +249,58 @@ func _return_from_results() -> void: main.show_screen("selection", "step3") +func _card(dark: bool, child: Control, accent: Color = Brand.ROYAL_BLUE, active: bool = false) -> PanelContainer: + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var box := Brand.content_card_box(dark, active, accent) + 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 _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 _style_progress(bar: ProgressBar, accent: Color, dark: bool) -> 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 _style_gradient_button(button: Button) -> void: + Brand.apply_font(button) + button.add_theme_font_size_override("font_size", 15) + button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + + +func _style_outline_button(button: Button, dark: bool) -> void: + Brand.apply_font(button) + button.add_theme_font_size_override("font_size", 14) + for state in ["normal", "hover", "pressed"]: + button.add_theme_stylebox_override(state, Brand.secondary_button_box(dark, state != "normal")) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + + func _clear(container: Control) -> void: for child in container.get_children(): - child.queue_free() + container.remove_child(child) + child.free()