Close the gap between the flashcards study screen and the mockup

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.
This commit is contained in:
hz
2026-07-22 00:43:52 -05:00
parent 70aa425fde
commit 09b670d5d4
21 changed files with 555 additions and 70 deletions
+3 -2
View File
@@ -95,7 +95,7 @@ func current_study_day_start_utc() -> String:
# ---------- Next card / study session ----------
func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String) -> void:
func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String, shuffle: bool = false) -> void:
if loading_next or not AuthState.can_send_authenticated_request():
next_card_failed.emit("Login is required to study flashcards.")
return
@@ -103,7 +103,8 @@ func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc:
var payload := {
"deckId": deck_id,
"excludeCardIds": exclude_ids,
"studyDayStartUtc": study_day_start_utc
"studyDayStartUtc": study_day_start_utc,
"shuffle": shuffle
}
var error := next_request.request(
"%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload))
@@ -0,0 +1 @@
uid://46eks1fy86s4
+1
View File
@@ -0,0 +1 @@
uid://co8k1cl8jr45x
+1
View File
@@ -0,0 +1 @@
uid://d0v44e6aprd1a
@@ -0,0 +1 @@
uid://cy1cu55mkv0wd
-25
View File
@@ -17,7 +17,6 @@ var deck_selector: OptionButton
var deck_ids: Array = []
var new_deck_button: Button
var study_button: Button
var export_button: Button
var import_button: Button
var search_input: LineEdit
var type_filter: OptionButton
@@ -41,7 +40,6 @@ var editor_tags_input: LineEdit
var editor_hint_label: Label
var editor_error_label: Label
var export_menu: PanelContainer
var import_menu: PanelContainer
var dropdown_backdrop: Control
@@ -100,7 +98,6 @@ func _ready() -> void:
FlashcardService.notes_loaded.connect(_on_notes_loaded)
FlashcardService.note_saved.connect(_on_note_saved)
FlashcardService.request_failed.connect(_on_request_failed)
FlashcardService.export_ready.connect(_on_export_ready)
FlashcardService.import_preview_ready.connect(_on_import_preview_ready)
FlashcardService.import_committed.connect(_on_import_committed)
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status())
@@ -228,20 +225,6 @@ func _build_toolbar() -> void:
FlashcardService.pick_import_file("json")],
])
export_button = Button.new()
export_button.text = "Export"
export_button.custom_minimum_size = Vector2(0, 44)
export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button))
row.add_child(export_button)
export_menu = _build_dropdown([
["Export CSV", func():
status_label.text = "Preparing export..."
FlashcardService.export_csv()],
["Export JSON", func():
status_label.text = "Preparing export..."
FlashcardService.export_json()],
])
func _style_primary_button(button: Button) -> void:
var normal := StyleBoxFlat.new()
@@ -301,8 +284,6 @@ func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void:
func _close_dropdowns() -> void:
if export_menu != null:
export_menu.visible = false
if import_menu != null:
import_menu.visible = false
if dropdown_backdrop != null:
@@ -684,10 +665,6 @@ func _start_study() -> void:
main.show_screen("flashcards_study", _selected_deck_id())
func _on_export_ready(location: String) -> void:
status_label.text = "Exported to %s." % location
# ---------- Import preview / commit ----------
func _build_import_overlay() -> void:
@@ -813,8 +790,6 @@ func _refresh_status() -> void:
editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if import_panel != null:
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if export_menu != null:
export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if import_menu != null:
import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if list_card != null:
+1
View File
@@ -0,0 +1 @@
uid://dkqsqq7b0dlgb
+258 -35
View File
@@ -6,6 +6,7 @@ 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
@@ -24,7 +25,26 @@ 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
@@ -34,13 +54,19 @@ 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
@@ -82,6 +108,9 @@ func _ready() -> void:
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 = {}
@@ -91,6 +120,7 @@ func on_show(payload: Variant = null) -> void:
undo_button.visible = false
_close_options_menu()
_render_loading()
_update_footer_button_states()
_load_next()
FlashcardService.fetch_decks()
FlashcardService.fetch_counts()
@@ -114,6 +144,17 @@ func _build() -> void:
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)
@@ -188,6 +229,16 @@ func _build() -> void:
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)
@@ -208,23 +259,44 @@ func _build() -> void:
grade_row.add_child(button)
grade_buttons[spec[0]] = button
var footer_row := HBoxContainer.new()
footer_row.add_theme_constant_override("separation", 10)
center_column.add_child(footer_row)
undo_button = Button.new()
undo_button.text = "Undo last review"
undo_button.custom_minimum_size = Vector2(0, 36)
undo_button.text = "Undo this rating"
undo_button.custom_minimum_size = Vector2(0, 40)
undo_button.visible = false
undo_button.pressed.connect(_undo)
footer_row.add_child(undo_button)
var footer_spacer := Control.new()
footer_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
footer_row.add_child(footer_spacer)
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:
@@ -267,6 +339,13 @@ func _side_panel_box(dark: bool) -> StyleBoxFlat:
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
@@ -279,6 +358,7 @@ func _panel_caption(text: String) -> Label:
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)
@@ -315,6 +395,7 @@ func _build_deck_panel() -> void:
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)
@@ -373,7 +454,7 @@ func _stat_row(parent: VBoxContainer, caption: String) -> Label:
return value
# ---------- Options menu (suspend / bury current card) ----------
# ---------- Options menu ----------
func _build_options_menu(footer_row: HBoxContainer) -> void:
options_backdrop = Control.new()
@@ -388,7 +469,7 @@ func _build_options_menu(footer_row: HBoxContainer) -> void:
options_button = Button.new()
options_button.text = "Options"
options_button.custom_minimum_size = Vector2(0, 36)
options_button.custom_minimum_size = Vector2(0, 44)
options_button.pressed.connect(_toggle_options_menu)
footer_row.add_child(options_button)
@@ -399,27 +480,31 @@ func _build_options_menu(footer_row: HBoxContainer) -> void:
var list := VBoxContainer.new()
list.add_theme_constant_override("separation", 2)
options_menu.add_child(list)
for item in [
["Bury card until tomorrow", func(): _bury_current_card()],
["Suspend card", func(): _suspend_current_card()],
]:
var button := Button.new()
button.text = str(item[0])
button.custom_minimum_size = Vector2(200, 40)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
var action: Callable = item[1]
button.pressed.connect(func():
_close_options_menu()
action.call())
list.add_child(button)
_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
if current_card.is_empty():
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)
@@ -433,10 +518,11 @@ func _close_options_menu() -> void:
func _bury_current_card() -> void:
if current_card.is_empty():
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()
@@ -444,10 +530,11 @@ func _bury_current_card() -> void:
func _suspend_current_card() -> void:
if current_card.is_empty():
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()
@@ -469,10 +556,18 @@ func refresh_theme_controls() -> void:
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()
@@ -548,9 +643,125 @@ func _refresh_stats_panel() -> void:
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)
FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc, shuffle_enabled)
func _on_next_card_loaded(card: Dictionary) -> void:
@@ -561,15 +772,22 @@ func _on_next_card_loaded(card: Dictionary) -> void:
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:
@@ -652,15 +870,15 @@ func _interval_label_for(rating: String) -> String:
func _grade(rating: String) -> void:
if current_card.is_empty():
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)
last_client_review_id = FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc)
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
undo_button.visible = true
undo_button.disabled = false
_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()
@@ -677,10 +895,13 @@ func _undo() -> void:
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 = ""
undo_button.visible = false
seen_card_ids.erase(last_card_id)
last_card_id = ""
seen_card_ids.erase(undone_card_id)
_return_to_live()
_load_next()
_render_loading()
FlashcardService.fetch_counts())
@@ -700,6 +921,8 @@ func _input(event: InputEvent) -> void:
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()
+1
View File
@@ -0,0 +1 @@
uid://cd26fs2ry8iij
+1 -1
View File
@@ -455,7 +455,7 @@ func _on_navigation_pressed(key: String) -> void:
"question_bank": show_screen("selection", "question_bank")
"exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub")
"flashcards": show_screen("flashcards_hub")
"flashcards": show_screen("flashcards_study")
"performance": show_screen("profile")
"study_plan": show_screen("map")
"settings": show_screen("settings")