extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") const Style := preload("res://scripts/ui/ui_style.gd") const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") var main var deck_id := "" var current_note: Dictionary = {} var current_card: Dictionary = {} var rating_options: Array = [] var revealed := false var last_client_review_id := "" var last_card_id := "" var study_day_start_utc := "" var seen_card_ids: Array = [] var reveal_started_ms := 0 var loaded_started_ms := 0 var awaiting_next := false var due_remaining := 0 var new_remaining := 0 var progress_label: Label var exit_button: Button var paused_banner: Label var card_panel: PanelContainer var front_label: Label var divider: ColorRect var answer_caption: Label var back_label: Label var extra_caption: Label var extra_label: Label var reveal_button: Button var grade_row: HBoxContainer var grade_buttons: Dictionary = {} var undo_button: Button var empty_label: Label var compact_layout := false func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 14) _build() resized.connect(_update_responsive_layout) call_deferred("_update_responsive_layout") FlashcardService.next_card_loaded.connect(_on_next_card_loaded) FlashcardService.next_card_failed.connect(_on_next_card_failed) FlashcardService.review_synced.connect(_on_review_synced) FlashcardService.review_paused.connect(_on_review_paused) FlashcardService.review_resumed.connect(_on_review_resumed) UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) func on_show(payload: Variant = null) -> void: deck_id = str(payload) if payload != null else "" seen_card_ids.clear() study_day_start_utc = FlashcardService.current_study_day_start_utc() current_note = {} current_card = {} revealed = false last_client_review_id = "" last_card_id = "" undo_button.visible = false _render_loading() _load_next() func on_hide() -> void: pass func _build() -> void: var root := VBoxContainer.new() root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.add_theme_constant_override("separation", 16) add_child(root) var top_row := HBoxContainer.new() root.add_child(top_row) exit_button = Button.new() exit_button.icon = preload("res://assets/icons/lucide/x.svg") exit_button.tooltip_text = "Exit study session" exit_button.custom_minimum_size = Vector2(44, 44) exit_button.pressed.connect(func(): main.show_screen("flashcards_hub")) top_row.add_child(exit_button) progress_label = Label.new() progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER progress_label.add_theme_font_size_override("font_size", 15) top_row.add_child(progress_label) var spacer := Control.new() spacer.custom_minimum_size = Vector2(44, 44) top_row.add_child(spacer) paused_banner = Label.new() paused_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER paused_banner.add_theme_color_override("font_color", Style.AMBER) paused_banner.visible = false root.add_child(paused_banner) var center := CenterContainer.new() center.size_flags_vertical = Control.SIZE_EXPAND_FILL center.size_flags_horizontal = Control.SIZE_EXPAND_FILL root.add_child(center) card_panel = PanelContainer.new() card_panel.custom_minimum_size = Vector2(720, 420) card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE)) center.add_child(card_panel) var card_content := VBoxContainer.new() card_content.add_theme_constant_override("separation", 20) card_panel.add_child(card_content) empty_label = Label.new() empty_label.text = "Nothing due right now. Great work!" empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER empty_label.visible = false card_content.add_child(empty_label) front_label = Label.new() front_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART front_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER front_label.add_theme_font_size_override("font_size", 25) card_content.add_child(front_label) divider = ColorRect.new() divider.custom_minimum_size = Vector2(0, 2) divider.visible = false card_content.add_child(divider) var answer_block := VBoxContainer.new() answer_block.add_theme_constant_override("separation", 4) card_content.add_child(answer_block) answer_caption = _caption_label("ANSWER") answer_block.add_child(answer_caption) back_label = Label.new() back_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART back_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER back_label.add_theme_font_size_override("font_size", 19) back_label.visible = false answer_block.add_child(back_label) var extra_block := VBoxContainer.new() extra_block.add_theme_constant_override("separation", 4) card_content.add_child(extra_block) extra_caption = _caption_label("KEY TAKEAWAY") extra_block.add_child(extra_caption) extra_label = Label.new() extra_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART extra_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER extra_label.add_theme_font_size_override("font_size", 15) extra_label.visible = false extra_block.add_child(extra_label) reveal_button = Button.new() reveal_button.text = "Show Answer (Space)" reveal_button.custom_minimum_size = Vector2(0, 52) _style_gradient_button(reveal_button) reveal_button.pressed.connect(_reveal) root.add_child(reveal_button) grade_row = HBoxContainer.new() grade_row.add_theme_constant_override("separation", 10) grade_row.visible = false root.add_child(grade_row) for spec in [["again", "Again", "1", Style.RED], ["hard", "Hard", "2", Style.AMBER], ["good", "Good", "3", Style.CYAN], ["easy", "Easy", "4", Style.LIME]]: var button := Button.new() button.custom_minimum_size = Vector2(0, 64) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_chip(button, spec[3]) button.pressed.connect(_grade.bind(spec[0])) grade_row.add_child(button) grade_buttons[spec[0]] = button undo_button = Button.new() undo_button.text = "Undo last review" undo_button.custom_minimum_size = Vector2(0, 36) undo_button.visible = false undo_button.pressed.connect(_undo) root.add_child(undo_button) _style_ghost_button(undo_button, UIThemeService.is_dark_mode()) func _caption_label(text: String) -> Label: var label := Label.new() label.text = text label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 12) label.visible = false return label func _style_gradient_button(button: Button) -> void: 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_ghost_button(button: Button, dark: bool) -> 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_stylebox_override("focus", empty) button.add_theme_color_override("font_color", Brand.muted_color(dark)) button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) func refresh_theme_controls() -> void: if card_panel == null: return var dark := UIThemeService.is_dark_mode() card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) divider.color = Brand.ROYAL_BLUE if dark else Brand.PURPLE answer_caption.add_theme_color_override("font_color", Brand.ROYAL_BLUE if dark else Brand.PURPLE) extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) extra_label.add_theme_color_override("font_color", Brand.muted_color(dark)) _style_ghost_button(undo_button, dark) func _update_responsive_layout() -> void: if card_panel == null: return compact_layout = size.x < 720.0 var available := maxf(size.x - 48.0, 240.0) card_panel.custom_minimum_size.x = minf(720.0, available) card_panel.custom_minimum_size.y = 340.0 if compact_layout else 420.0 func _load_next() -> void: awaiting_next = true FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc) func _on_next_card_loaded(card: Dictionary) -> void: awaiting_next = false var note: Variant = card.get("note") var schedule: Variant = card.get("card") if typeof(note) != TYPE_DICTIONARY or typeof(schedule) != TYPE_DICTIONARY: current_note = {} current_card = {} _render_empty(card) return current_note = note current_card = schedule rating_options = card.get("ratingOptions", []) due_remaining = int(card.get("dueRemaining", 0)) new_remaining = int(card.get("newRemaining", 0)) revealed = false loaded_started_ms = Time.get_ticks_msec() _render_card() func _on_next_card_failed(message: String) -> void: awaiting_next = false progress_label.text = message func _render_loading() -> void: empty_label.visible = false front_label.visible = true front_label.text = "Loading..." _hide_answer() reveal_button.visible = false grade_row.visible = false func _render_empty(card: Dictionary) -> void: empty_label.visible = true front_label.visible = false _hide_answer() reveal_button.visible = false grade_row.visible = false progress_label.text = "0 due" if typeof(card) == TYPE_DICTIONARY: progress_label.text = "%d due, %d new available" % [int(card.get("dueRemaining", 0)), int(card.get("newRemaining", 0))] func _hide_answer() -> void: divider.visible = false answer_caption.visible = false back_label.visible = false extra_caption.visible = false extra_label.visible = false func _render_card() -> void: empty_label.visible = false var note_type := str(current_note.get("type", "basic")) var variant := int(current_card.get("variant", 0)) if note_type == "cloze": front_label.text = Cloze.render_front(str(current_note.get("prompt", "")), variant) else: front_label.text = str(current_note.get("prompt", "")) front_label.visible = true _hide_answer() reveal_button.visible = true grade_row.visible = false progress_label.text = "%d due, %d new available" % [due_remaining, new_remaining] func _reveal() -> void: if current_note.is_empty(): return revealed = true reveal_started_ms = Time.get_ticks_msec() var note_type := str(current_note.get("type", "basic")) if note_type == "cloze": back_label.text = Cloze.render_back(str(current_note.get("prompt", ""))) else: back_label.text = str(current_note.get("answer", "")) var extra := str(current_note.get("extra", "")) divider.visible = true answer_caption.visible = true back_label.visible = true extra_caption.visible = not extra.is_empty() extra_label.visible = not extra.is_empty() extra_label.text = extra reveal_button.visible = false grade_row.visible = true for rating in grade_buttons: var button: Button = grade_buttons[rating] button.text = "%s\n%s" % [rating.capitalize(), _interval_label_for(rating)] func _interval_label_for(rating: String) -> String: for option in rating_options: if str(option.get("rating", "")) == rating: return str(option.get("intervalLabel", "")) return "" func _grade(rating: String) -> void: if current_card.is_empty(): return var card_id := str(current_card.get("id", "")) var expected_revision := int(current_card.get("revision", 0)) var duration_seconds := int(float(Time.get_ticks_msec() - loaded_started_ms) / 1000.0) last_client_review_id = FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) last_card_id = card_id undo_button.visible = true undo_button.disabled = false if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() _render_loading() func _on_review_synced(_card_id: String, _updated_card: Dictionary) -> void: pass func _undo() -> void: if last_client_review_id.is_empty(): return undo_button.disabled = true FlashcardService.undo_last_review(last_client_review_id, func(_restored: Dictionary): last_client_review_id = "" undo_button.visible = false seen_card_ids.erase(last_card_id) _load_next()) func _on_review_paused(message: String) -> void: paused_banner.text = message paused_banner.visible = true func _on_review_resumed() -> void: paused_banner.visible = false if awaiting_next: _load_next() func _input(event: InputEvent) -> void: if not visible or not is_visible_in_tree(): return if not (event is InputEventKey) or not event.pressed or event.echo: return if not revealed: if event.keycode == KEY_SPACE: _reveal() get_viewport().set_input_as_handled() return var rating := rating_for_keycode(event.keycode) if not rating.is_empty(): _grade(rating) get_viewport().set_input_as_handled() static func rating_for_keycode(keycode: Key) -> String: match keycode: KEY_1: return "again" KEY_2: return "hard" KEY_3: return "good" KEY_4: return "easy" _: return ""