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") const CircularProgress := preload("res://scripts/ui/circular_progress.gd") const MASTERY_STABILITY_LABEL := "Cards graduate to \"mastered\" once they've held up in review for a few weeks straight." 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 paused_banner: Label var main_row: HBoxContainer 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 options_button: Button var options_menu: PanelContainer var options_backdrop: Control var empty_label: Label var compact_layout := false var deck_panel: PanelContainer var deck_name_label: Label var deck_ring: Control var deck_mastered_label: Label var change_deck_button: Button var stats_panel: PanelContainer var stats_new_ring: Control var stats_due_label: Label var stats_learning_label: Label var stats_review_label: Label var streak_label: Label var daily_goal_label: Label var daily_goal_bar: ProgressBar var decks: Array = [] var latest_counts: Dictionary = {} 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) FlashcardService.decks_changed.connect(_on_decks_changed) FlashcardService.counts_changed.connect(_on_counts_changed) 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 _close_options_menu() _render_loading() _load_next() FlashcardService.fetch_decks() FlashcardService.fetch_counts() 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) 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) 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) main_row = HBoxContainer.new() main_row.size_flags_vertical = Control.SIZE_EXPAND_FILL main_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL main_row.add_theme_constant_override("separation", 20) root.add_child(main_row) _build_deck_panel() var center_column := VBoxContainer.new() center_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL center_column.size_flags_vertical = Control.SIZE_EXPAND_FILL center_column.add_theme_constant_override("separation", 16) main_row.add_child(center_column) var center := CenterContainer.new() center.size_flags_vertical = Control.SIZE_EXPAND_FILL center.size_flags_horizontal = Control.SIZE_EXPAND_FILL center_column.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) center_column.add_child(reveal_button) grade_row = HBoxContainer.new() grade_row.add_theme_constant_override("separation", 10) grade_row.visible = false center_column.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 var footer_row := HBoxContainer.new() footer_row.add_theme_constant_override("separation", 10) center_column.add_child(footer_row) 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) footer_row.add_child(undo_button) var footer_spacer := Control.new() footer_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer_row.add_child(footer_spacer) _build_options_menu(footer_row) _build_stats_panel() refresh_theme_controls() 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 _side_panel_box(dark: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("081426") if dark else Color.WHITE box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER box.set_border_width_all(1) box.set_corner_radius_all(10) box.content_margin_left = 18 box.content_margin_right = 18 box.content_margin_top = 18 box.content_margin_bottom = 18 return box func _panel_caption(text: String) -> Label: var label := Label.new() label.text = text label.add_theme_font_size_override("font_size", 12) return label # ---------- Left panel: current deck ---------- func _build_deck_panel() -> void: deck_panel = PanelContainer.new() deck_panel.custom_minimum_size = Vector2(220, 0) main_row.add_child(deck_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) deck_panel.add_child(content) content.add_child(_panel_caption("CURRENT DECK")) deck_name_label = Label.new() deck_name_label.text = "All decks" deck_name_label.add_theme_font_size_override("font_size", 18) deck_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART content.add_child(deck_name_label) var ring_center := CenterContainer.new() content.add_child(ring_center) deck_ring = CircularProgress.new() deck_ring.tooltip_text = MASTERY_STABILITY_LABEL ring_center.add_child(deck_ring) deck_mastered_label = Label.new() deck_mastered_label.text = "-- cards mastered" deck_mastered_label.add_theme_font_size_override("font_size", 12) deck_mastered_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART content.add_child(deck_mastered_label) change_deck_button = Button.new() change_deck_button.text = "Change Deck" change_deck_button.custom_minimum_size = Vector2(0, 44) change_deck_button.pressed.connect(func(): main.show_screen("flashcards_hub")) content.add_child(change_deck_button) # ---------- Right panel: spaced repetition stats ---------- func _build_stats_panel() -> void: stats_panel = PanelContainer.new() stats_panel.custom_minimum_size = Vector2(220, 0) main_row.add_child(stats_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) stats_panel.add_child(content) content.add_child(_panel_caption("SPACED REPETITION")) var ring_center := CenterContainer.new() content.add_child(ring_center) stats_new_ring = CircularProgress.new() ring_center.add_child(stats_new_ring) stats_due_label = _stat_row(content, "Due") stats_learning_label = _stat_row(content, "Learning") stats_review_label = _stat_row(content, "Review") var streak_row := HBoxContainer.new() streak_row.add_theme_constant_override("separation", 10) content.add_child(streak_row) var streak_caption := VBoxContainer.new() streak_caption.add_theme_constant_override("separation", 0) streak_row.add_child(streak_caption) streak_caption.add_child(_panel_caption("STREAK")) streak_label = Label.new() streak_label.text = "-- days" streak_label.add_theme_font_size_override("font_size", 16) streak_caption.add_child(streak_label) var goal_block := VBoxContainer.new() goal_block.add_theme_constant_override("separation", 4) content.add_child(goal_block) goal_block.add_child(_panel_caption("DAILY GOAL")) daily_goal_label = Label.new() daily_goal_label.text = "-- / --" daily_goal_label.add_theme_font_size_override("font_size", 16) goal_block.add_child(daily_goal_label) daily_goal_bar = ProgressBar.new() daily_goal_bar.custom_minimum_size = Vector2(0, 8) daily_goal_bar.show_percentage = false daily_goal_bar.min_value = 0 daily_goal_bar.max_value = 1 goal_block.add_child(daily_goal_bar) func _stat_row(parent: VBoxContainer, caption: String) -> Label: var row := HBoxContainer.new() parent.add_child(row) var caption_label := Label.new() caption_label.text = caption caption_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(caption_label) var value := Label.new() value.text = "--" value.add_theme_font_size_override("font_size", 15) row.add_child(value) return value # ---------- Options menu (suspend / bury current card) ---------- func _build_options_menu(footer_row: HBoxContainer) -> void: options_backdrop = Control.new() options_backdrop.visible = false options_backdrop.top_level = true options_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP options_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) options_backdrop.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.pressed: _close_options_menu()) add_child(options_backdrop) options_button = Button.new() options_button.text = "Options" options_button.custom_minimum_size = Vector2(0, 36) options_button.pressed.connect(_toggle_options_menu) footer_row.add_child(options_button) options_menu = PanelContainer.new() options_menu.visible = false options_menu.top_level = true add_child(options_menu) var list := VBoxContainer.new() list.add_theme_constant_override("separation", 2) options_menu.add_child(list) for item in [ ["Bury card until tomorrow", func(): _bury_current_card()], ["Suspend card", func(): _suspend_current_card()], ]: var button := Button.new() button.text = str(item[0]) button.custom_minimum_size = Vector2(200, 40) button.alignment = HORIZONTAL_ALIGNMENT_LEFT var action: Callable = item[1] button.pressed.connect(func(): _close_options_menu() action.call()) list.add_child(button) func _toggle_options_menu() -> void: if options_menu.visible: _close_options_menu() return if current_card.is_empty(): return options_menu.visible = true options_backdrop.visible = true options_menu.position = options_button.get_global_position() - Vector2(0, options_menu.size.y + 4) func _close_options_menu() -> void: if options_menu != null: options_menu.visible = false if options_backdrop != null: options_backdrop.visible = false func _bury_current_card() -> void: if current_card.is_empty(): return var card_id := str(current_card.get("id", "")) FlashcardService.bury_cards([card_id], _bury_until_tomorrow_utc()) if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() _render_loading() func _suspend_current_card() -> void: if current_card.is_empty(): return var card_id := str(current_card.get("id", "")) FlashcardService.suspend_cards([card_id], true) if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() _render_loading() func _bury_until_tomorrow_utc() -> String: var study_day_unix := Time.get_unix_time_from_datetime_string(study_day_start_utc.trim_suffix("Z")) return "%sZ" % Time.get_datetime_string_from_unix_time(study_day_unix + 86400, false) 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) deck_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) stats_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) options_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) _refresh_deck_panel() _refresh_stats_panel() func _update_responsive_layout() -> void: if card_panel == null: return compact_layout = size.x < 1080.0 deck_panel.visible = not compact_layout stats_panel.visible = not compact_layout var available := maxf((size.x - 48.0) if compact_layout else (size.x - 520.0), 240.0) card_panel.custom_minimum_size.x = minf(720.0, available) card_panel.custom_minimum_size.y = 340.0 if size.x < 720.0 else 420.0 # ---------- Deck / stats data ---------- func _on_decks_changed(loaded_decks: Array) -> void: decks = loaded_decks _refresh_deck_panel() func _refresh_deck_panel() -> void: if deck_ring == null: return var dark := UIThemeService.is_dark_mode() var name := "All decks" var total := 0 var mastered := 0 if deck_id.is_empty(): for summary in decks: total += int(summary.get("total", 0)) mastered += int(summary.get("mastered", 0)) else: for summary in decks: var deck: Dictionary = summary.get("deck", {}) if str(deck.get("id", "")) == deck_id: name = str(deck.get("name", "Deck")) total = int(summary.get("total", 0)) mastered = int(summary.get("mastered", 0)) break deck_name_label.text = name var fraction := float(mastered) / float(total) if total > 0 else 0.0 deck_ring.configure(fraction, "%d%%" % roundi(fraction * 100.0), "Mastery", Brand.ROYAL_BLUE, dark) deck_mastered_label.text = "%d / %d cards mastered" % [mastered, total] func _on_counts_changed(counts: Dictionary) -> void: latest_counts = counts _refresh_stats_panel() func _refresh_stats_panel() -> void: if stats_new_ring == null or latest_counts.is_empty(): return var dark := UIThemeService.is_dark_mode() var due := int(latest_counts.get("due", 0)) var new_available := int(latest_counts.get("newAvailable", 0)) var learning := int(latest_counts.get("learning", 0)) var review := int(latest_counts.get("review", 0)) var streak := int(latest_counts.get("currentStreakDays", 0)) var reviews_today := int(latest_counts.get("reviewsToday", 0)) var daily_goal := int(latest_counts.get("dailyGoal", 0)) var new_fraction := float(new_available) / float(new_available + due) if (new_available + due) > 0 else 0.0 stats_new_ring.configure(new_fraction, str(new_available), "New", Brand.ROYAL_BLUE, dark) stats_due_label.text = str(due) stats_learning_label.text = str(learning) stats_review_label.text = str(review) streak_label.text = "%d day%s" % [streak, "" if streak == 1 else "s"] daily_goal_label.text = "%d / %d" % [reviews_today, daily_goal] daily_goal_bar.max_value = maxf(1.0, float(daily_goal)) daily_goal_bar.value = float(reviews_today) 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() FlashcardService.fetch_counts() 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() FlashcardService.fetch_counts()) 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 ""