System decks/notes are owned by a reserved sentinel user id, reusing all existing per-user store/index plumbing unchanged. Regular users see them under a separate "Official Decks" section, can study them with fully independent per-user FSRS progress (lazily materialized on first visit), but can never edit or archive them - enforced both in the client (no Edit/Archive controls) and server-side (ownership guard on every mutating endpoint). Includes a small idempotent seed command (`dotnet run -- seed-flashcards`) for testing, since there's no in-app authoring UI yet.
955 lines
32 KiB
GDScript
955 lines
32 KiB
GDScript
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."
|
||
const PROGRESS_PIP_COUNT := 5
|
||
|
||
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
|
||
|
||
# Session history: every card that leaves the "live" slot (graded, skipped, buried, or
|
||
# suspended) is pushed here, so Previous/Next can page back through what happened this session.
|
||
# Invariant: history_cursor == session_history.size() means "at the live card"; anything less
|
||
# means we're viewing a past entry in read-only mode.
|
||
var session_history: Array = []
|
||
var history_cursor := 0
|
||
var session_total := -1
|
||
var shuffle_enabled := false
|
||
|
||
# Shadow copy of the true in-flight card, populated only while browsing history so the live
|
||
# card is never lost and can be restored exactly via _return_to_live().
|
||
var live_note: Dictionary = {}
|
||
var live_card: Dictionary = {}
|
||
var live_rating_options: Array = []
|
||
var live_revealed := false
|
||
var live_due_remaining := 0
|
||
var live_new_remaining := 0
|
||
|
||
var progress_label: Label
|
||
var progress_pips: Array = []
|
||
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 card_position_label: Label
|
||
var reveal_button: Button
|
||
var grade_row: HBoxContainer
|
||
var grade_buttons: Dictionary = {}
|
||
var undo_button: Button
|
||
var previous_button: Button
|
||
var next_button: Button
|
||
var shuffle_button: Button
|
||
var options_button: Button
|
||
var options_menu: PanelContainer
|
||
var options_backdrop: Control
|
||
var options_bury_button: Button
|
||
var options_suspend_button: Button
|
||
var empty_label: Label
|
||
var compact_layout := false
|
||
|
||
var deck_panel: PanelContainer
|
||
var deck_name_label: Label
|
||
var deck_system_badge: 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()
|
||
session_history.clear()
|
||
history_cursor = 0
|
||
session_total = -1
|
||
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()
|
||
_update_footer_button_states()
|
||
_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)
|
||
|
||
var pips_row := HBoxContainer.new()
|
||
pips_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
pips_row.add_theme_constant_override("separation", 6)
|
||
root.add_child(pips_row)
|
||
progress_pips = []
|
||
for i in PROGRESS_PIP_COUNT:
|
||
var pip := PanelContainer.new()
|
||
pip.custom_minimum_size = Vector2(48, 6)
|
||
pips_row.add_child(pip)
|
||
progress_pips.append(pip)
|
||
|
||
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)
|
||
|
||
var position_row := HBoxContainer.new()
|
||
card_content.add_child(position_row)
|
||
var position_spacer := Control.new()
|
||
position_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
position_row.add_child(position_spacer)
|
||
card_position_label = Label.new()
|
||
card_position_label.add_theme_font_size_override("font_size", 12)
|
||
card_position_label.visible = false
|
||
position_row.add_child(card_position_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
|
||
|
||
undo_button = Button.new()
|
||
undo_button.text = "Undo this rating"
|
||
undo_button.custom_minimum_size = Vector2(0, 40)
|
||
undo_button.visible = false
|
||
undo_button.pressed.connect(_undo)
|
||
center_column.add_child(undo_button)
|
||
|
||
var footer_center := CenterContainer.new()
|
||
center_column.add_child(footer_center)
|
||
var footer_row := HBoxContainer.new()
|
||
footer_row.add_theme_constant_override("separation", 10)
|
||
footer_center.add_child(footer_row)
|
||
|
||
previous_button = Button.new()
|
||
previous_button.text = "‹ Previous"
|
||
previous_button.custom_minimum_size = Vector2(120, 44)
|
||
previous_button.pressed.connect(_go_previous)
|
||
footer_row.add_child(previous_button)
|
||
|
||
shuffle_button = Button.new()
|
||
shuffle_button.text = "Shuffle"
|
||
shuffle_button.toggle_mode = true
|
||
shuffle_button.custom_minimum_size = Vector2(110, 44)
|
||
shuffle_button.toggled.connect(func(pressed: bool): shuffle_enabled = pressed)
|
||
footer_row.add_child(shuffle_button)
|
||
|
||
_build_options_menu(footer_row)
|
||
|
||
next_button = Button.new()
|
||
next_button.text = "Next ›"
|
||
next_button.custom_minimum_size = Vector2(120, 44)
|
||
next_button.pressed.connect(_go_next)
|
||
footer_row.add_child(next_button)
|
||
|
||
_build_stats_panel()
|
||
|
||
refresh_theme_controls()
|
||
_update_footer_button_states()
|
||
|
||
|
||
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 _pip_box(filled: bool, dark: bool) -> StyleBoxFlat:
|
||
var box := StyleBoxFlat.new()
|
||
box.bg_color = Brand.ROYAL_BLUE if filled else (Color("26324a") if dark else Color("e2e8f0"))
|
||
box.set_corner_radius_all(3)
|
||
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)
|
||
deck_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
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)
|
||
|
||
deck_system_badge = Label.new()
|
||
deck_system_badge.text = "Official Deck"
|
||
deck_system_badge.add_theme_font_size_override("font_size", 12)
|
||
deck_system_badge.add_theme_color_override("font_color", Brand.PURPLE)
|
||
deck_system_badge.visible = false
|
||
content.add_child(deck_system_badge)
|
||
|
||
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)
|
||
stats_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
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 ----------
|
||
|
||
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, 44)
|
||
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)
|
||
|
||
_options_menu_item(list, "Manage Flashcards", func(): main.show_screen("flashcards_hub"))
|
||
options_bury_button = _options_menu_item(list, "Bury card until tomorrow", func(): _bury_current_card())
|
||
options_suspend_button = _options_menu_item(list, "Suspend card", func(): _suspend_current_card())
|
||
|
||
|
||
func _options_menu_item(list: VBoxContainer, text: String, action: Callable) -> Button:
|
||
var button := Button.new()
|
||
button.text = text
|
||
button.custom_minimum_size = Vector2(200, 40)
|
||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
button.pressed.connect(func():
|
||
_close_options_menu()
|
||
action.call())
|
||
list.add_child(button)
|
||
return button
|
||
|
||
|
||
func _toggle_options_menu() -> void:
|
||
if options_menu.visible:
|
||
_close_options_menu()
|
||
return
|
||
var at_live_with_card := history_cursor == session_history.size() and not current_card.is_empty()
|
||
options_bury_button.disabled = not at_live_with_card
|
||
options_suspend_button.disabled = not at_live_with_card
|
||
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 history_cursor != session_history.size() or current_card.is_empty():
|
||
return
|
||
var card_id := str(current_card.get("id", ""))
|
||
FlashcardService.bury_cards([card_id], _bury_until_tomorrow_utc())
|
||
_push_history("buried", card_id)
|
||
if not seen_card_ids.has(card_id):
|
||
seen_card_ids.append(card_id)
|
||
_load_next()
|
||
_render_loading()
|
||
|
||
|
||
func _suspend_current_card() -> void:
|
||
if history_cursor != session_history.size() or current_card.is_empty():
|
||
return
|
||
var card_id := str(current_card.get("id", ""))
|
||
FlashcardService.suspend_cards([card_id], true)
|
||
_push_history("suspended", card_id)
|
||
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))
|
||
card_position_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||
_style_ghost_button(undo_button, dark)
|
||
previous_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||
previous_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||
next_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||
next_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||
options_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||
options_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||
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))
|
||
_update_progress_pips(history_cursor)
|
||
_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
|
||
var is_system := false
|
||
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))
|
||
is_system = bool(deck.get("isSystem", false))
|
||
break
|
||
deck_name_label.text = name
|
||
deck_system_badge.visible = is_system
|
||
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)
|
||
|
||
|
||
# ---------- Session history / navigation ----------
|
||
|
||
func _push_history(action: String, card_id: String, rating: String = "", client_review_id: String = "") -> void:
|
||
var entry := {
|
||
"note": current_note, "card": current_card, "rating_options": rating_options,
|
||
"action": action, "card_id": card_id
|
||
}
|
||
if not rating.is_empty():
|
||
entry["rating"] = rating
|
||
entry["client_review_id"] = client_review_id
|
||
session_history.append(entry)
|
||
history_cursor = session_history.size()
|
||
|
||
|
||
func _snapshot_live_before_browsing() -> void:
|
||
live_note = current_note
|
||
live_card = current_card
|
||
live_rating_options = rating_options
|
||
live_revealed = revealed
|
||
live_due_remaining = due_remaining
|
||
live_new_remaining = new_remaining
|
||
|
||
|
||
func _return_to_live() -> void:
|
||
history_cursor = session_history.size()
|
||
current_note = live_note
|
||
current_card = live_card
|
||
rating_options = live_rating_options
|
||
due_remaining = live_due_remaining
|
||
new_remaining = live_new_remaining
|
||
undo_button.visible = false
|
||
revealed = false
|
||
if current_note.is_empty() and current_card.is_empty():
|
||
_render_empty({"dueRemaining": due_remaining, "newRemaining": new_remaining})
|
||
else:
|
||
_render_card()
|
||
if live_revealed:
|
||
_reveal()
|
||
_update_card_position_label(history_cursor)
|
||
_update_progress_pips(history_cursor)
|
||
_update_footer_button_states()
|
||
|
||
|
||
func _render_history_entry(index: int) -> void:
|
||
var entry: Dictionary = session_history[index]
|
||
current_note = entry.get("note", {})
|
||
current_card = entry.get("card", {})
|
||
rating_options = entry.get("rating_options", [])
|
||
revealed = false
|
||
_render_card()
|
||
_reveal()
|
||
grade_row.visible = false
|
||
reveal_button.visible = false
|
||
var is_graded_entry: bool = entry.get("action", "") == "graded"
|
||
var is_last_graded_card: bool = str(entry.get("card_id", "")) == last_card_id
|
||
var has_undoable_review: bool = not last_client_review_id.is_empty()
|
||
undo_button.visible = is_graded_entry and is_last_graded_card and has_undoable_review
|
||
undo_button.disabled = false
|
||
undo_button.text = "Undo this rating"
|
||
_update_card_position_label(index)
|
||
_update_progress_pips(index)
|
||
_update_footer_button_states()
|
||
|
||
|
||
func _go_previous() -> void:
|
||
if history_cursor <= 0:
|
||
return
|
||
if history_cursor == session_history.size():
|
||
_snapshot_live_before_browsing()
|
||
history_cursor -= 1
|
||
_render_history_entry(history_cursor)
|
||
|
||
|
||
func _go_next() -> void:
|
||
if history_cursor >= session_history.size():
|
||
_skip_current_card()
|
||
return
|
||
history_cursor += 1
|
||
if history_cursor >= session_history.size():
|
||
_return_to_live()
|
||
else:
|
||
_render_history_entry(history_cursor)
|
||
|
||
|
||
func _skip_current_card() -> void:
|
||
if current_card.is_empty() or awaiting_next:
|
||
return
|
||
var card_id := str(current_card.get("id", ""))
|
||
_push_history("skipped", card_id)
|
||
if not seen_card_ids.has(card_id):
|
||
seen_card_ids.append(card_id)
|
||
_load_next()
|
||
_render_loading()
|
||
|
||
|
||
func _update_card_position_label(position_index: int) -> void:
|
||
card_position_label.visible = session_total > 0
|
||
if session_total > 0:
|
||
card_position_label.text = "Card %d of %d" % [position_index + 1, maxi(session_total, position_index + 1)]
|
||
|
||
|
||
func _update_progress_pips(position_index: int) -> void:
|
||
var dark := UIThemeService.is_dark_mode()
|
||
var fraction := 0.0 if session_total <= 0 else float(position_index) / float(session_total)
|
||
var filled := clampi(floori(fraction * progress_pips.size()), 0, progress_pips.size())
|
||
for i in progress_pips.size():
|
||
var pip: PanelContainer = progress_pips[i]
|
||
pip.add_theme_stylebox_override("panel", _pip_box(i < filled, dark))
|
||
|
||
|
||
func _update_footer_button_states() -> void:
|
||
var at_live := history_cursor == session_history.size()
|
||
previous_button.disabled = history_cursor <= 0
|
||
next_button.disabled = at_live and current_card.is_empty()
|
||
|
||
|
||
func _load_next() -> void:
|
||
awaiting_next = true
|
||
FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc, shuffle_enabled)
|
||
|
||
|
||
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)
|
||
_update_footer_button_states()
|
||
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))
|
||
if session_total < 0:
|
||
session_total = due_remaining + new_remaining
|
||
history_cursor = session_history.size()
|
||
revealed = false
|
||
loaded_started_ms = Time.get_ticks_msec()
|
||
_render_card()
|
||
_update_card_position_label(history_cursor)
|
||
_update_progress_pips(history_cursor)
|
||
_update_footer_button_states()
|
||
|
||
|
||
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() or history_cursor != session_history.size():
|
||
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)
|
||
var client_review_id := FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc)
|
||
last_client_review_id = client_review_id
|
||
last_card_id = card_id
|
||
_push_history("graded", card_id, rating, client_review_id)
|
||
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):
|
||
var undone_card_id := last_card_id
|
||
last_client_review_id = ""
|
||
last_card_id = ""
|
||
seen_card_ids.erase(undone_card_id)
|
||
_return_to_live()
|
||
_load_next()
|
||
_render_loading()
|
||
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 history_cursor != session_history.size():
|
||
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 ""
|