Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
944 lines
32 KiB
GDScript
944 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_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)
|
||
|
||
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
|
||
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)
|
||
|
||
|
||
# ---------- 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 ""
|