Files
step3/scripts/ui/flashcards_study.gd
T
hz e8d615985b Reveal the flashcard answer by tapping the card, not a separate button
Matches the mockup: the "Show Answer" button is replaced by an in-card
"Tap card to reveal answer" hint, and clicking anywhere on the card
reveals it (guarded the same way Space already was - only while at the
live card and not yet revealed).
2026-07-22 17:21:23 -05:00

958 lines
32 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 tap_hint_label: Label
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))
card_panel.mouse_filter = Control.MOUSE_FILTER_STOP
card_panel.gui_input.connect(_on_card_panel_input)
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 tap_hint_center := CenterContainer.new()
card_content.add_child(tap_hint_center)
tap_hint_label = Label.new()
tap_hint_label.text = "Tap card to reveal answer"
tap_hint_label.visible = false
tap_hint_center.add_child(tap_hint_label)
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)
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_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))
tap_hint_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
tap_hint_label.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()
tap_hint_label.visible = false
grade_row.visible = false
func _render_empty(card: Dictionary) -> void:
empty_label.visible = true
front_label.visible = false
_hide_answer()
tap_hint_label.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()
divider.visible = true
tap_hint_label.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
tap_hint_label.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 _on_card_panel_input(event: InputEvent) -> void:
if history_cursor != session_history.size() or revealed:
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_reveal()
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 ""