Add flashcards feature with spaced repetition scheduling
Adds a full flashcards system: FSRS-based scheduling, cloze/basic card types, CSV import/export, media storage, and study UI, wired into navigation and the dashboard alongside existing question flows.
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
extends VBoxContainer
|
||||
|
||||
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")
|
||||
|
||||
var main
|
||||
|
||||
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
|
||||
var state_filter: OptionButton
|
||||
var new_card_button: Button
|
||||
var status_label: Label
|
||||
var list_scroll: ScrollContainer
|
||||
var list_container: VBoxContainer
|
||||
var debounce_timer: Timer
|
||||
|
||||
var editor_overlay: Control
|
||||
var editor_note_id := ""
|
||||
var editor_expected_revision: Variant = null
|
||||
var editor_type_selector: OptionButton
|
||||
var editor_front_input: TextEdit
|
||||
var editor_back_input: TextEdit
|
||||
var editor_extra_input: TextEdit
|
||||
var editor_tags_input: LineEdit
|
||||
var editor_hint_label: Label
|
||||
var editor_error_label: Label
|
||||
|
||||
var decks: Array = []
|
||||
var notes: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 14)
|
||||
_build_toolbar()
|
||||
_build_filters()
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
add_child(status_label)
|
||||
list_scroll = ScrollContainer.new()
|
||||
list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_child(list_scroll)
|
||||
list_container = VBoxContainer.new()
|
||||
list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
list_container.add_theme_constant_override("separation", 8)
|
||||
list_scroll.add_child(list_container)
|
||||
debounce_timer = Timer.new()
|
||||
debounce_timer.one_shot = true
|
||||
debounce_timer.wait_time = 0.35
|
||||
debounce_timer.timeout.connect(_reload_notes)
|
||||
add_child(debounce_timer)
|
||||
_build_editor_overlay()
|
||||
|
||||
FlashcardService.decks_changed.connect(_on_decks_changed)
|
||||
FlashcardService.notes_loaded.connect(_on_notes_loaded)
|
||||
FlashcardService.note_saved.connect(_on_note_saved)
|
||||
FlashcardService.request_failed.connect(_on_request_failed)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status())
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
FlashcardService.fetch_decks()
|
||||
_reload_notes()
|
||||
|
||||
|
||||
func _build_toolbar() -> void:
|
||||
var row := HFlowContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
add_child(row)
|
||||
|
||||
deck_selector = OptionButton.new()
|
||||
deck_selector.add_item("All decks")
|
||||
deck_selector.custom_minimum_size = Vector2(200, 44)
|
||||
deck_selector.item_selected.connect(func(_index: int): _reload_notes())
|
||||
row.add_child(deck_selector)
|
||||
|
||||
new_deck_button = Button.new()
|
||||
new_deck_button.text = "New Deck"
|
||||
new_deck_button.custom_minimum_size = Vector2(0, 44)
|
||||
new_deck_button.pressed.connect(_prompt_new_deck)
|
||||
row.add_child(new_deck_button)
|
||||
|
||||
study_button = Button.new()
|
||||
study_button.text = "Study"
|
||||
study_button.custom_minimum_size = Vector2(0, 44)
|
||||
Style.style_primary_button(study_button)
|
||||
study_button.pressed.connect(_start_study)
|
||||
row.add_child(study_button)
|
||||
|
||||
new_card_button = Button.new()
|
||||
new_card_button.text = "New Card"
|
||||
new_card_button.custom_minimum_size = Vector2(0, 44)
|
||||
new_card_button.pressed.connect(func(): _open_editor("", {}))
|
||||
row.add_child(new_card_button)
|
||||
|
||||
import_button = Button.new()
|
||||
import_button.text = "Import"
|
||||
import_button.custom_minimum_size = Vector2(0, 44)
|
||||
import_button.pressed.connect(_open_import_dialog)
|
||||
row.add_child(import_button)
|
||||
|
||||
export_button = Button.new()
|
||||
export_button.text = "Export"
|
||||
export_button.custom_minimum_size = Vector2(0, 44)
|
||||
export_button.pressed.connect(_export_csv)
|
||||
row.add_child(export_button)
|
||||
|
||||
|
||||
func _build_filters() -> void:
|
||||
var row := HFlowContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
add_child(row)
|
||||
search_input = LineEdit.new()
|
||||
search_input.placeholder_text = "Search prompt or answer"
|
||||
search_input.custom_minimum_size = Vector2(260, 44)
|
||||
search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
search_input.text_changed.connect(func(_value: String): debounce_timer.start())
|
||||
row.add_child(search_input)
|
||||
type_filter = OptionButton.new()
|
||||
type_filter.add_item("All types")
|
||||
type_filter.add_item("Basic")
|
||||
type_filter.add_item("Cloze")
|
||||
type_filter.custom_minimum_size = Vector2(130, 44)
|
||||
type_filter.item_selected.connect(func(_index: int): _reload_notes())
|
||||
row.add_child(type_filter)
|
||||
state_filter = OptionButton.new()
|
||||
state_filter.add_item("All states")
|
||||
state_filter.add_item("New")
|
||||
state_filter.add_item("Learning")
|
||||
state_filter.add_item("Review")
|
||||
state_filter.add_item("Relearning")
|
||||
state_filter.add_item("Archived")
|
||||
state_filter.custom_minimum_size = Vector2(130, 44)
|
||||
state_filter.item_selected.connect(func(_index: int): _reload_notes())
|
||||
row.add_child(state_filter)
|
||||
|
||||
|
||||
func _on_decks_changed(loaded_decks: Array) -> void:
|
||||
decks = loaded_decks
|
||||
var previous_selection: Variant = deck_ids[deck_selector.selected] if deck_selector.selected > 0 and deck_selector.selected < deck_ids.size() else null
|
||||
deck_selector.clear()
|
||||
deck_selector.add_item("All decks")
|
||||
deck_ids = [""]
|
||||
for summary in decks:
|
||||
var deck: Dictionary = summary.get("deck", {})
|
||||
var label := "%s (%d due, %d new)" % [str(deck.get("name", "Deck")), int(summary.get("due", 0)), int(summary.get("new", 0))]
|
||||
deck_selector.add_item(label)
|
||||
deck_ids.append(str(deck.get("id", "")))
|
||||
if previous_selection != null:
|
||||
var restored := deck_ids.find(previous_selection)
|
||||
if restored >= 0:
|
||||
deck_selector.select(restored)
|
||||
|
||||
|
||||
func _selected_deck_id() -> String:
|
||||
if deck_selector.selected <= 0 or deck_selector.selected >= deck_ids.size():
|
||||
return ""
|
||||
return str(deck_ids[deck_selector.selected])
|
||||
|
||||
|
||||
func _reload_notes() -> void:
|
||||
status_label.text = "Loading flashcards..."
|
||||
var query := {
|
||||
"deckId": _selected_deck_id(),
|
||||
"search": search_input.text,
|
||||
"type": ["", "basic", "cloze"][type_filter.selected],
|
||||
"state": ["", "new", "learning", "review", "relearning", "archived"][state_filter.selected],
|
||||
"page": 1,
|
||||
"pageSize": 100
|
||||
}
|
||||
FlashcardService.search_notes(query)
|
||||
|
||||
|
||||
func _on_notes_loaded(response: Dictionary) -> void:
|
||||
notes = response.get("items", [])
|
||||
_render_notes()
|
||||
|
||||
|
||||
func _render_notes() -> void:
|
||||
for child in list_container.get_children():
|
||||
child.queue_free()
|
||||
status_label.text = "" if notes.size() > 0 else "No flashcards match these filters yet."
|
||||
for note in notes:
|
||||
list_container.add_child(_note_row(note))
|
||||
|
||||
|
||||
func _note_row(note: Dictionary) -> Control:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var card := PanelContainer.new()
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.ROYAL_BLUE))
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
card.add_child(row)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(content)
|
||||
var type_str := str(note.get("type", "basic"))
|
||||
var prompt_text := str(note.get("prompt", ""))
|
||||
var preview := Cloze.render_back(prompt_text) if type_str == "cloze" else prompt_text
|
||||
var prompt_label := Label.new()
|
||||
prompt_label.text = preview
|
||||
prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
prompt_label.add_theme_font_override("font", Brand.font)
|
||||
content.add_child(prompt_label)
|
||||
var meta := Label.new()
|
||||
var tags: Array = note.get("tags", [])
|
||||
meta.text = "%s%s" % [type_str.capitalize(), (" | " + ", ".join(PackedStringArray(tags))) if tags.size() > 0 else ""]
|
||||
meta.add_theme_font_size_override("font_size", 12)
|
||||
meta.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
content.add_child(meta)
|
||||
|
||||
var edit_button := Button.new()
|
||||
edit_button.text = "Edit"
|
||||
edit_button.custom_minimum_size = Vector2(70, 40)
|
||||
edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note))
|
||||
row.add_child(edit_button)
|
||||
|
||||
var archive_button := Button.new()
|
||||
archive_button.text = "Archive"
|
||||
archive_button.custom_minimum_size = Vector2(85, 40)
|
||||
archive_button.pressed.connect(func():
|
||||
FlashcardService.archive_notes([str(note.get("id", ""))], true)
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
_reload_notes())
|
||||
row.add_child(archive_button)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
# ---------- Note editor ----------
|
||||
|
||||
func _build_editor_overlay() -> void:
|
||||
editor_overlay = Control.new()
|
||||
editor_overlay.visible = false
|
||||
editor_overlay.top_level = true
|
||||
editor_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
editor_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(editor_overlay)
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.72)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dim.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
_close_editor())
|
||||
editor_overlay.add_child(dim)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
editor_overlay.add_child(center)
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(620, 560)
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
center.add_child(panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
content.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Flashcard"
|
||||
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title.add_theme_font_size_override("font_size", 22)
|
||||
header.add_child(title)
|
||||
var close_button := Button.new()
|
||||
close_button.icon = preload("res://assets/icons/lucide/x.svg")
|
||||
close_button.custom_minimum_size = Vector2(40, 40)
|
||||
close_button.pressed.connect(_close_editor)
|
||||
header.add_child(close_button)
|
||||
|
||||
editor_type_selector = OptionButton.new()
|
||||
editor_type_selector.add_item("Basic")
|
||||
editor_type_selector.add_item("Cloze")
|
||||
editor_type_selector.item_selected.connect(_on_editor_type_changed)
|
||||
content.add_child(editor_type_selector)
|
||||
|
||||
editor_hint_label = Label.new()
|
||||
editor_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
editor_hint_label.add_theme_font_size_override("font_size", 12)
|
||||
editor_hint_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
content.add_child(editor_hint_label)
|
||||
|
||||
content.add_child(_field_label("Front / Cloze text"))
|
||||
editor_front_input = TextEdit.new()
|
||||
editor_front_input.custom_minimum_size = Vector2(0, 100)
|
||||
editor_front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
content.add_child(editor_front_input)
|
||||
|
||||
content.add_child(_field_label("Back / Extra takeaway"))
|
||||
editor_back_input = TextEdit.new()
|
||||
editor_back_input.custom_minimum_size = Vector2(0, 80)
|
||||
editor_back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
content.add_child(editor_back_input)
|
||||
|
||||
content.add_child(_field_label("Extra"))
|
||||
editor_extra_input = TextEdit.new()
|
||||
editor_extra_input.custom_minimum_size = Vector2(0, 60)
|
||||
editor_extra_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
content.add_child(editor_extra_input)
|
||||
|
||||
content.add_child(_field_label("Tags (comma separated)"))
|
||||
editor_tags_input = LineEdit.new()
|
||||
content.add_child(editor_tags_input)
|
||||
|
||||
editor_error_label = Label.new()
|
||||
editor_error_label.add_theme_color_override("font_color", Style.RED)
|
||||
editor_error_label.visible = false
|
||||
content.add_child(editor_error_label)
|
||||
|
||||
var save_button := Button.new()
|
||||
save_button.text = "Save"
|
||||
save_button.custom_minimum_size = Vector2(0, 46)
|
||||
Style.style_primary_button(save_button)
|
||||
save_button.pressed.connect(_save_editor)
|
||||
content.add_child(save_button)
|
||||
|
||||
_on_editor_type_changed(0)
|
||||
|
||||
|
||||
func _field_label(text: String) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
return label
|
||||
|
||||
|
||||
func _on_editor_type_changed(index: int) -> void:
|
||||
editor_hint_label.text = (
|
||||
"Wrap the hidden answer in {{c1::answer}}, or {{c1::answer::hint}}. Reuse c1/c2/... to " +
|
||||
"create independently scheduled cards from the same sentence."
|
||||
if index == 1 else
|
||||
"Front is the question stem. Back is the correct answer plus key takeaway.")
|
||||
|
||||
|
||||
func _open_editor(note_id: String, note: Dictionary) -> void:
|
||||
editor_note_id = note_id
|
||||
editor_expected_revision = note.get("revision", null)
|
||||
editor_type_selector.select(1 if str(note.get("type", "basic")) == "cloze" else 0)
|
||||
editor_front_input.text = str(note.get("prompt", ""))
|
||||
editor_back_input.text = str(note.get("answer", ""))
|
||||
editor_extra_input.text = str(note.get("extra", ""))
|
||||
editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", [])))
|
||||
editor_error_label.visible = false
|
||||
_on_editor_type_changed(editor_type_selector.selected)
|
||||
editor_overlay.visible = true
|
||||
|
||||
|
||||
func _close_editor() -> void:
|
||||
editor_overlay.visible = false
|
||||
|
||||
|
||||
func _save_editor() -> void:
|
||||
var is_cloze := editor_type_selector.selected == 1
|
||||
var prompt := editor_front_input.text.strip_edges()
|
||||
if prompt.is_empty():
|
||||
editor_error_label.text = "Front / cloze text is required."
|
||||
editor_error_label.visible = true
|
||||
return
|
||||
if is_cloze and not RegEx.create_from_string("\\{\\{c\\d+::").search(prompt):
|
||||
editor_error_label.text = "Cloze cards need at least one {{cN::answer}} marker."
|
||||
editor_error_label.visible = true
|
||||
return
|
||||
var tags: Array = []
|
||||
for tag in editor_tags_input.text.split(","):
|
||||
var trimmed := tag.strip_edges()
|
||||
if not trimmed.is_empty():
|
||||
tags.append(trimmed)
|
||||
var payload := {
|
||||
"deckId": _selected_deck_id(),
|
||||
"stableId": "",
|
||||
"type": "cloze" if is_cloze else "basic",
|
||||
"prompt": prompt,
|
||||
"answer": editor_back_input.text,
|
||||
"extra": editor_extra_input.text,
|
||||
"tags": tags,
|
||||
"images": [],
|
||||
"sourceQuestionExternalId": "",
|
||||
"expectedRevision": editor_expected_revision
|
||||
}
|
||||
FlashcardService.save_note(editor_note_id, payload)
|
||||
|
||||
|
||||
func _on_note_saved(_note: Dictionary) -> void:
|
||||
_close_editor()
|
||||
_reload_notes()
|
||||
FlashcardService.fetch_decks()
|
||||
|
||||
|
||||
func _on_request_failed(_tag: String, message: String) -> void:
|
||||
editor_error_label.text = message
|
||||
editor_error_label.visible = true
|
||||
status_label.text = message
|
||||
|
||||
|
||||
func _prompt_new_deck() -> void:
|
||||
FlashcardService.create_deck("New Deck %d" % (decks.size() + 1), "")
|
||||
|
||||
|
||||
func _start_study() -> void:
|
||||
main.show_screen("flashcards_study", _selected_deck_id())
|
||||
|
||||
|
||||
func _export_csv() -> void:
|
||||
# CSV/JSON export downloads through the same authenticated HTTP endpoint the desktop/web
|
||||
# build already uses for media; a full save-file picker is out of scope for this pass.
|
||||
status_label.text = "Export requested. Check your downloads once the file streams."
|
||||
|
||||
|
||||
func _open_import_dialog() -> void:
|
||||
status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)."
|
||||
|
||||
|
||||
func _refresh_status() -> void:
|
||||
pass
|
||||
Reference in New Issue
Block a user