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,178 @@
|
||||
extends Control
|
||||
|
||||
# Prefilled basic-card editor opened from the question toolbar once a question's answer has
|
||||
# been resolved. Saving creates a new flashcard note in the user's default deck; the user can
|
||||
# move/retag it from the flashcards library afterward.
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
|
||||
signal saved
|
||||
|
||||
var front_input: TextEdit
|
||||
var back_input: TextEdit
|
||||
var tags_input: LineEdit
|
||||
var image_checkbox: CheckBox
|
||||
var error_label: Label
|
||||
var save_button: Button
|
||||
var pending_image_asset_id := ""
|
||||
var source_question_external_id := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
top_level = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
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:
|
||||
hide_dialog())
|
||||
add_child(dim)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(600, 520)
|
||||
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 = "Save as 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(hide_dialog)
|
||||
header.add_child(close_button)
|
||||
|
||||
content.add_child(_field_label("Front"))
|
||||
front_input = TextEdit.new()
|
||||
front_input.custom_minimum_size = Vector2(0, 110)
|
||||
front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
content.add_child(front_input)
|
||||
|
||||
content.add_child(_field_label("Back"))
|
||||
back_input = TextEdit.new()
|
||||
back_input.custom_minimum_size = Vector2(0, 140)
|
||||
back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
content.add_child(back_input)
|
||||
|
||||
content.add_child(_field_label("Tags (comma separated)"))
|
||||
tags_input = LineEdit.new()
|
||||
content.add_child(tags_input)
|
||||
|
||||
image_checkbox = CheckBox.new()
|
||||
image_checkbox.text = "Attach question image"
|
||||
image_checkbox.visible = false
|
||||
content.add_child(image_checkbox)
|
||||
|
||||
error_label = Label.new()
|
||||
error_label.add_theme_color_override("font_color", Style.RED)
|
||||
error_label.visible = false
|
||||
content.add_child(error_label)
|
||||
|
||||
save_button = Button.new()
|
||||
save_button.text = "Save Flashcard"
|
||||
save_button.custom_minimum_size = Vector2(0, 48)
|
||||
Style.style_primary_button(save_button)
|
||||
save_button.pressed.connect(_save)
|
||||
content.add_child(save_button)
|
||||
|
||||
FlashcardService.note_saved.connect(_on_saved)
|
||||
FlashcardService.request_failed.connect(_on_failed)
|
||||
|
||||
|
||||
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 open_for_question(question: Dictionary, current_answer: Dictionary) -> void:
|
||||
var stem := str(question.get("stem", "")).strip_edges()
|
||||
var lead_in := str(question.get("lead_in", "")).strip_edges()
|
||||
front_input.text = "%s\n\n%s" % [stem, lead_in] if not lead_in.is_empty() else stem
|
||||
|
||||
var choices: Array = question.get("choices", [])
|
||||
var correct_id := str(question.get("correct_choice_id", ""))
|
||||
var correct_text := ""
|
||||
for choice in choices:
|
||||
if str(choice.get("id", "")) == correct_id:
|
||||
correct_text = str(choice.get("text", ""))
|
||||
break
|
||||
var takeaway := str(question.get("key_takeaway", "")).strip_edges()
|
||||
if takeaway.is_empty():
|
||||
takeaway = str(question.get("explanation", "")).strip_edges()
|
||||
back_input.text = "%s\n\n%s" % [correct_text, takeaway] if not takeaway.is_empty() else correct_text
|
||||
|
||||
var tags: Array = []
|
||||
var system := str(question.get("system", "")).strip_edges()
|
||||
if not system.is_empty():
|
||||
tags.append(system)
|
||||
for tag in question.get("tags", []):
|
||||
var trimmed := str(tag).strip_edges()
|
||||
if not trimmed.is_empty() and not tags.has(trimmed):
|
||||
tags.append(trimmed)
|
||||
tags_input.text = ", ".join(PackedStringArray(tags))
|
||||
|
||||
var media: Array = question.get("media", [])
|
||||
pending_image_asset_id = str(media[0]) if media.size() > 0 else ""
|
||||
image_checkbox.visible = not pending_image_asset_id.is_empty()
|
||||
image_checkbox.button_pressed = false
|
||||
source_question_external_id = str(question.get("id", ""))
|
||||
|
||||
error_label.visible = false
|
||||
visible = true
|
||||
|
||||
|
||||
func hide_dialog() -> void:
|
||||
visible = false
|
||||
|
||||
|
||||
func _save() -> void:
|
||||
var tags: Array = []
|
||||
for tag in tags_input.text.split(","):
|
||||
var trimmed := tag.strip_edges()
|
||||
if not trimmed.is_empty():
|
||||
tags.append(trimmed)
|
||||
var images: Array = []
|
||||
if image_checkbox.button_pressed and not pending_image_asset_id.is_empty():
|
||||
images.append({"kind": "question", "assetId": pending_image_asset_id})
|
||||
var payload := {
|
||||
"deckId": "",
|
||||
"stableId": "",
|
||||
"type": "basic",
|
||||
"prompt": front_input.text,
|
||||
"answer": back_input.text,
|
||||
"extra": "",
|
||||
"tags": tags,
|
||||
"images": images,
|
||||
"sourceQuestionExternalId": source_question_external_id,
|
||||
"expectedRevision": null
|
||||
}
|
||||
FlashcardService.save_note("", payload)
|
||||
|
||||
|
||||
func _on_saved(_note: Dictionary) -> void:
|
||||
if not visible:
|
||||
return
|
||||
hide_dialog()
|
||||
saved.emit()
|
||||
|
||||
|
||||
func _on_failed(_tag: String, message: String) -> void:
|
||||
if not visible:
|
||||
return
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
Reference in New Issue
Block a user