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:
@@ -244,6 +244,10 @@ func _render_review(data: Dictionary) -> void:
|
||||
missed_action.disabled = missed_count <= 0 or pending_review_launch
|
||||
missed_action.pressed.connect(_start_review_queue)
|
||||
review_list.add_child(missed_action)
|
||||
var flashcards_due := int(data.get("flashcardsDue", 0))
|
||||
review_list.add_child(_review_action(
|
||||
"%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date",
|
||||
"clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards"))
|
||||
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
|
||||
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
extends RefCounted
|
||||
|
||||
# Mirrors api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs so the study screen can
|
||||
# render a cloze note's front/back without a round trip to the server.
|
||||
|
||||
static func _regex() -> RegEx:
|
||||
var regex := RegEx.new()
|
||||
regex.compile("(?s)\\{\\{c(\\d+)::((?:(?!\\}\\}).)*)\\}\\}")
|
||||
return regex
|
||||
|
||||
|
||||
static func render_front(text: String, cloze_number: int) -> String:
|
||||
var regex := _regex()
|
||||
var result := ""
|
||||
var last_end := 0
|
||||
for match in regex.search_all(text):
|
||||
result += text.substr(last_end, match.get_start() - last_end)
|
||||
var number := int(match.get_string(1))
|
||||
var inner := match.get_string(2)
|
||||
var parts := inner.split("::", true, 1)
|
||||
if number == cloze_number:
|
||||
var hint := parts[1].strip_edges() if parts.size() > 1 else ""
|
||||
result += ("[%s]" % hint) if not hint.is_empty() else "[...]"
|
||||
else:
|
||||
result += parts[0].strip_edges()
|
||||
last_end = match.get_end()
|
||||
result += text.substr(last_end)
|
||||
return result
|
||||
|
||||
|
||||
static func render_back(text: String) -> String:
|
||||
var regex := _regex()
|
||||
var result := ""
|
||||
var last_end := 0
|
||||
for match in regex.search_all(text):
|
||||
result += text.substr(last_end, match.get_start() - last_end)
|
||||
var inner := match.get_string(2)
|
||||
result += inner.split("::", true, 1)[0].strip_edges()
|
||||
last_end = match.get_end()
|
||||
result += text.substr(last_end)
|
||||
return result
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,318 @@
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
var progress_label: Label
|
||||
var exit_button: Button
|
||||
var paused_banner: Label
|
||||
var card_panel: PanelContainer
|
||||
var front_label: Label
|
||||
var divider: HSeparator
|
||||
var back_label: Label
|
||||
var reveal_button: Button
|
||||
var grade_row: HBoxContainer
|
||||
var grade_buttons: Dictionary = {}
|
||||
var undo_button: Button
|
||||
var empty_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_theme_constant_override("separation", 14)
|
||||
_build()
|
||||
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)
|
||||
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()
|
||||
study_day_start_utc = FlashcardService.current_study_day_start_utc()
|
||||
current_note = {}
|
||||
current_card = {}
|
||||
revealed = false
|
||||
_render_loading()
|
||||
_load_next()
|
||||
|
||||
|
||||
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)
|
||||
exit_button = Button.new()
|
||||
exit_button.icon = preload("res://assets/icons/lucide/x.svg")
|
||||
exit_button.tooltip_text = "Exit study session"
|
||||
exit_button.custom_minimum_size = Vector2(44, 44)
|
||||
exit_button.pressed.connect(func(): main.show_screen("flashcards_hub"))
|
||||
top_row.add_child(exit_button)
|
||||
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 spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(44, 44)
|
||||
top_row.add_child(spacer)
|
||||
|
||||
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)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
root.add_child(center)
|
||||
|
||||
card_panel = PanelContainer.new()
|
||||
card_panel.custom_minimum_size = Vector2(640, 360)
|
||||
center.add_child(card_panel)
|
||||
var card_content := VBoxContainer.new()
|
||||
card_content.add_theme_constant_override("separation", 18)
|
||||
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", 22)
|
||||
card_content.add_child(front_label)
|
||||
|
||||
divider = HSeparator.new()
|
||||
divider.visible = false
|
||||
card_content.add_child(divider)
|
||||
|
||||
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", 18)
|
||||
back_label.visible = false
|
||||
card_content.add_child(back_label)
|
||||
|
||||
reveal_button = Button.new()
|
||||
reveal_button.text = "Show Answer (Space)"
|
||||
reveal_button.custom_minimum_size = Vector2(0, 52)
|
||||
Style.style_primary_button(reveal_button)
|
||||
reveal_button.pressed.connect(_reveal)
|
||||
root.add_child(reveal_button)
|
||||
|
||||
grade_row = HBoxContainer.new()
|
||||
grade_row.add_theme_constant_override("separation", 10)
|
||||
grade_row.visible = false
|
||||
root.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 last review"
|
||||
undo_button.custom_minimum_size = Vector2(0, 42)
|
||||
undo_button.disabled = true
|
||||
undo_button.pressed.connect(_undo)
|
||||
root.add_child(undo_button)
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _load_next() -> void:
|
||||
awaiting_next = true
|
||||
FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc)
|
||||
|
||||
|
||||
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)
|
||||
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))
|
||||
revealed = false
|
||||
loaded_started_ms = Time.get_ticks_msec()
|
||||
_render_card()
|
||||
|
||||
|
||||
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..."
|
||||
back_label.visible = false
|
||||
divider.visible = false
|
||||
reveal_button.visible = false
|
||||
grade_row.visible = false
|
||||
|
||||
|
||||
func _render_empty(card: Dictionary) -> void:
|
||||
empty_label.visible = true
|
||||
front_label.visible = false
|
||||
back_label.visible = false
|
||||
divider.visible = false
|
||||
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 _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
|
||||
back_label.visible = false
|
||||
divider.visible = false
|
||||
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:
|
||||
var answer := str(current_note.get("answer", ""))
|
||||
var extra := str(current_note.get("extra", ""))
|
||||
back_label.text = "%s\n\n%s" % [answer, extra] if not extra.is_empty() else answer
|
||||
back_label.visible = true
|
||||
divider.visible = true
|
||||
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():
|
||||
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)
|
||||
last_card_id = card_id
|
||||
undo_button.disabled = false
|
||||
if not seen_card_ids.has(card_id):
|
||||
seen_card_ids.append(card_id)
|
||||
_load_next()
|
||||
_render_loading()
|
||||
|
||||
|
||||
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):
|
||||
last_client_review_id = ""
|
||||
seen_card_ids.erase(last_card_id)
|
||||
_load_next())
|
||||
|
||||
|
||||
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 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 ""
|
||||
+13
-2
@@ -93,7 +93,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void:
|
||||
func _apply_screen_margins(screen_name: String) -> void:
|
||||
if screens_margin == null or screen_name.is_empty():
|
||||
return
|
||||
var is_fullscreen_study := screen_name == "player"
|
||||
var is_fullscreen_study := screen_name in ["player", "flashcards_study"]
|
||||
var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44))
|
||||
screens_margin.add_theme_constant_override("margin_left", margin)
|
||||
screens_margin.add_theme_constant_override("margin_right", margin)
|
||||
@@ -310,6 +310,13 @@ func _build_ui() -> void:
|
||||
var ccs_history := preload("res://scripts/ui/ccs_history.gd").new()
|
||||
ccs_history.name = "CcsHistory"; ccs_history.main = self; screens_container.add_child(ccs_history); screens["ccs_history"] = ccs_history
|
||||
|
||||
var flashcards_hub := preload("res://scripts/ui/flashcards_hub.gd").new()
|
||||
flashcards_hub.name = "FlashcardsHub"; flashcards_hub.main = self
|
||||
screens_container.add_child(flashcards_hub); screens["flashcards_hub"] = flashcards_hub
|
||||
var flashcards_study := preload("res://scripts/ui/flashcards_study.gd").new()
|
||||
flashcards_study.name = "FlashcardsStudy"; flashcards_study.main = self
|
||||
screens_container.add_child(flashcards_study); screens["flashcards_study"] = flashcards_study
|
||||
|
||||
var profile := preload("res://scripts/ui/progress_profile.gd").new()
|
||||
profile.name = "ProgressProfile"
|
||||
profile.main = self
|
||||
@@ -404,6 +411,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
|
||||
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
|
||||
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
|
||||
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
|
||||
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
||||
|
||||
@@ -447,6 +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")
|
||||
"performance": show_screen("profile")
|
||||
"study_plan": show_screen("map")
|
||||
"settings": show_screen("settings")
|
||||
@@ -464,6 +473,8 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"selection": "Question Bank",
|
||||
"simulation_history": "Exam Simulations",
|
||||
"ccs_hub": "CCS Cases",
|
||||
"flashcards_hub": "Flashcards",
|
||||
"flashcards_study": "Flashcards",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"notes": "Notes",
|
||||
@@ -555,7 +566,7 @@ func _close_account_menu() -> void:
|
||||
|
||||
|
||||
func _screen_uses_shell(screen_name: String) -> bool:
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "flashcards_study"]
|
||||
|
||||
|
||||
func _update_shell_visibility(screen_name: String) -> void:
|
||||
|
||||
@@ -15,6 +15,8 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "exams"
|
||||
elif screen_name.begins_with("ccs_"):
|
||||
key = "ccs"
|
||||
elif screen_name.begins_with("flashcards_"):
|
||||
key = "flashcards"
|
||||
elif screen_name == "profile":
|
||||
key = "performance"
|
||||
elif screen_name == "settings":
|
||||
|
||||
@@ -2,6 +2,7 @@ extends VBoxContainer
|
||||
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
||||
@@ -84,6 +85,8 @@ var clear_highlight_button: Button
|
||||
var flag_button: Button
|
||||
var report_button: Button
|
||||
var notes_button: Button
|
||||
var flashcard_button: Button
|
||||
var flashcard_prefill_dialog: Control
|
||||
var bookmark_button: Button
|
||||
var app_feedback_button: Button
|
||||
var theme_button: Button
|
||||
@@ -280,6 +283,22 @@ func _ready() -> void:
|
||||
)
|
||||
question_toolbar.add_child(flag_button)
|
||||
|
||||
flashcard_button = Button.new()
|
||||
flashcard_button.tooltip_text = "Save as flashcard (available once answered)"
|
||||
flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg")
|
||||
flashcard_button.custom_minimum_size = Vector2(44, 44)
|
||||
flashcard_button.add_theme_constant_override("icon_max_width", 22)
|
||||
flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
flashcard_button.focus_mode = Control.FOCUS_NONE
|
||||
flashcard_button.disabled = true
|
||||
UISoundService.set_button_sound(flashcard_button, "toggle")
|
||||
flashcard_button.pressed.connect(_toggle_flashcard_current_question)
|
||||
question_toolbar.add_child(flashcard_button)
|
||||
flashcard_prefill_dialog = FlashcardPrefillDialog.new()
|
||||
flashcard_prefill_dialog.name = "FlashcardPrefillDialog"
|
||||
add_child(flashcard_prefill_dialog)
|
||||
|
||||
bookmark_button = Button.new()
|
||||
bookmark_button.tooltip_text = "Save question"
|
||||
bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg")
|
||||
@@ -865,7 +884,7 @@ func _apply_question_theme() -> void:
|
||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||
if navigator_heading != null:
|
||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for control in [text_smaller_button, text_larger_button, flag_button, bookmark_button, notes_button, app_feedback_button]:
|
||||
for control in [text_smaller_button, text_larger_button, flag_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
if control == null:
|
||||
continue
|
||||
var active: bool = (control == flag_button and flagged) or (control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", ""))))
|
||||
@@ -1423,6 +1442,7 @@ func _show_answer_review() -> void:
|
||||
skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true
|
||||
if question_scroll != null:
|
||||
question_scroll.call_deferred("ensure_control_visible", explanation_label)
|
||||
_refresh_toolbar_state()
|
||||
|
||||
|
||||
func _set_answer_review_timer_visibility() -> void:
|
||||
@@ -1969,6 +1989,9 @@ func _refresh_toolbar_state() -> void:
|
||||
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
||||
if notes_button != null:
|
||||
notes_button.visible = BlockSessionService.session_kind != "simulation"
|
||||
if flashcard_button != null:
|
||||
flashcard_button.visible = BlockSessionService.session_kind != "simulation"
|
||||
flashcard_button.disabled = current_answer.is_empty()
|
||||
if bookmark_button != null:
|
||||
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
|
||||
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||
@@ -1984,6 +2007,15 @@ func _toggle_bookmark_current_question() -> void:
|
||||
BookmarkService.toggle_question(question)
|
||||
|
||||
|
||||
func _toggle_flashcard_current_question() -> void:
|
||||
if current_answer.is_empty():
|
||||
return
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if question.is_empty() or flashcard_prefill_dialog == null:
|
||||
return
|
||||
flashcard_prefill_dialog.open_for_question(question, current_answer)
|
||||
|
||||
|
||||
func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void:
|
||||
if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user