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:
hz
2026-07-21 15:08:52 -05:00
parent 29b89dc404
commit 48a8c8361d
41 changed files with 4458 additions and 8 deletions
+375
View File
@@ -0,0 +1,375 @@
extends Node
# Flashcard autoload: study-session networking (next card, review submission) plus best-effort
# CRUD for decks/notes/settings used by the library screens.
#
# Review submission is the one path that must survive a dropped connection mid-session: graded
# reviews are queued to a durable per-account outbox (mirrors ReviewQueueService) and retried
# until the server confirms them. Everything else (deck/note CRUD, counts) is a plain
# best-effort request - if it fails, the caller shows an error and the user retries manually.
signal decks_changed(decks: Array)
signal settings_changed(settings: Dictionary)
signal counts_changed(counts: Dictionary)
signal next_card_loaded(card: Dictionary)
signal next_card_failed(message: String)
signal review_synced(card_id: String, updated_card: Dictionary)
signal review_paused(message: String)
signal review_resumed
signal notes_loaded(response: Dictionary)
signal note_saved(note: Dictionary)
signal request_failed(tag: String, message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards"
const OUTBOX_PATH := "user://pending_flashcard_reviews.json"
var api_url := DEFAULT_API_URL
var next_request: HTTPRequest
var review_request: HTTPRequest
var misc_request: HTTPRequest
var retry_timer: Timer
var loading_next := false
var submitting_review := false
var misc_busy := false
var misc_queue: Array = []
var outbox: Array = []
var active_account_key := ""
var is_paused := false
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/flashcards_api_url", DEFAULT_API_URL)),
"/api/flashcards")
next_request = _make_request(_on_next_completed)
review_request = _make_request(_on_review_completed)
misc_request = _make_request(_on_misc_completed)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.wait_time = 5.0
retry_timer.timeout.connect(_process_outbox)
add_child(retry_timer)
_load_outbox()
AuthState.auth_changed.connect(_on_auth_changed)
_on_auth_changed()
func _make_request(callback: Callable) -> HTTPRequest:
var request := HTTPRequest.new()
request.timeout = 25.0
request.request_completed.connect(callback)
add_child(request)
return request
func _headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
func current_study_day_start_utc() -> String:
# The local calendar day at session start, expressed as a UTC instant, per the spec's
# "daily limits are based on the client's local study day recorded at session start."
var local := Time.get_datetime_dict_from_system(false)
var utc_offset_minutes: int = Time.get_time_zone_from_system().get("bias", 0)
var local_midnight_unix: int = Time.get_unix_time_from_datetime_dict({
"year": local.year, "month": local.month, "day": local.day,
"hour": 0, "minute": 0, "second": 0
})
var utc_unix: int = local_midnight_unix + utc_offset_minutes * 60
return "%sZ" % Time.get_datetime_string_from_unix_time(utc_unix, true)
# ---------- Next card / study session ----------
func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String) -> void:
if loading_next or not AuthState.can_send_authenticated_request():
next_card_failed.emit("Login is required to study flashcards.")
return
loading_next = true
var payload := {
"deckId": deck_id,
"excludeCardIds": exclude_ids,
"studyDayStartUtc": study_day_start_utc
}
var error := next_request.request(
"%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload))
if error != OK:
loading_next = false
next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.")
func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
loading_next = false
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
next_card_failed.emit("The flashcard service returned an invalid response.")
return
next_card_loaded.emit(parsed)
# ---------- Review submission (offline-safe outbox) ----------
func submit_review(card_id: String, rating: String, expected_revision: int, duration_seconds: int, study_day_start_utc: String) -> String:
var client_review_id := _new_uuid()
var payload := {
"clientReviewId": client_review_id,
"cardId": card_id,
"rating": rating,
"expectedRevision": expected_revision,
"reviewedUtc": "%sZ" % Time.get_datetime_string_from_system(true),
"durationSeconds": clampi(duration_seconds, 0, 86400),
"studyDayStartUtc": study_day_start_utc
}
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
_save_outbox()
_process_outbox()
return client_review_id
func has_pending_reviews() -> bool:
return _next_account_item_index() >= 0
func _process_outbox() -> void:
if submitting_review or not AuthState.can_send_authenticated_request():
return
var index := _next_account_item_index()
if index < 0:
if is_paused:
is_paused = false
review_resumed.emit()
return
var item: Dictionary = outbox[index]
item["active"] = true
outbox[index] = item
submitting_review = true
var error := review_request.request(
"%s/reviews" % api_url, _headers(), HTTPClient.METHOD_POST,
JSON.stringify({"reviews": [item.get("payload", {})]}))
if error != OK:
submitting_review = false
item.erase("active")
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
_mark_paused()
retry_timer.start()
func _on_review_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
submitting_review = false
var index := _active_item_index()
if index < 0:
return
var item: Dictionary = outbox[index]
item.erase("active")
var card_id := str(item.get("payload", {}).get("cardId", ""))
if response_code == 401:
outbox[index] = item
_save_outbox()
AuthState.handle_unauthorized(AuthState.access_token)
return
if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300:
outbox.remove_at(index)
_save_outbox()
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) == TYPE_DICTIONARY:
var results: Array = parsed.get("results", [])
if results.size() > 0:
review_synced.emit(card_id, (results[0] as Dictionary).get("card", {}))
_process_outbox()
return
if response_code == 409 or (response_code >= 400 and response_code < 500 and response_code not in [408, 429]):
# Stale revision or otherwise rejected: don't retry forever with bad state.
outbox.remove_at(index)
_save_outbox()
_process_outbox()
return
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
_mark_paused()
retry_timer.start()
func _mark_paused() -> void:
if not is_paused:
is_paused = true
review_paused.emit("Reconnecting to sync your reviews. Your progress is saved.")
# ---------- Best-effort JSON CRUD (decks, notes, settings, counts, undo, cards) ----------
func fetch_decks() -> void:
_enqueue_json("GET", "/decks", "", func(parsed): decks_changed.emit(parsed if typeof(parsed) == TYPE_ARRAY else []), "decks")
func fetch_settings() -> void:
_enqueue_json("GET", "/settings", "", func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings")
func update_settings(payload: Dictionary) -> void:
_enqueue_json("PUT", "/settings", JSON.stringify(payload), func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings")
func fetch_counts() -> void:
_enqueue_json("GET", "/counts", "", func(parsed): counts_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "counts")
func create_deck(name: String, description: String) -> void:
_enqueue_json("POST", "/decks", JSON.stringify({"name": name, "description": description}),
func(_parsed): fetch_decks(), "decks")
func archive_deck(deck_id: String, archived: bool) -> void:
var action := "archive" if archived else "restore"
_enqueue_json("POST", "/decks/%s/%s" % [deck_id, action], "", func(_parsed): fetch_decks(), "decks")
func search_notes(query: Dictionary) -> void:
_enqueue_json("POST", "/notes/search", JSON.stringify(query),
func(parsed): notes_loaded.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {"items": [], "total": 0}), "notes")
func save_note(note_id: String, payload: Dictionary) -> void:
var path := "/notes/%s" % note_id if not note_id.is_empty() else "/notes"
var method := "PUT" if not note_id.is_empty() else "POST"
_enqueue_json(method, path, JSON.stringify(payload),
func(parsed): note_saved.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "note_save")
func move_notes(note_ids: Array, target_deck_id: String) -> void:
_enqueue_json("POST", "/notes/move", JSON.stringify({"noteIds": note_ids, "targetDeckId": target_deck_id}),
func(_parsed): pass, "notes")
func archive_notes(note_ids: Array, archived: bool) -> void:
var action := "archive" if archived else "restore"
_enqueue_json("POST", "/notes/%s" % action, JSON.stringify({"noteIds": note_ids}), func(_parsed): pass, "notes")
func suspend_cards(card_ids: Array, suspended: bool) -> void:
_enqueue_json("POST", "/cards/suspend", JSON.stringify({"cardIds": card_ids, "suspended": suspended}), func(_parsed): pass, "cards")
func bury_cards(card_ids: Array, until_utc: String) -> void:
_enqueue_json("POST", "/cards/bury", JSON.stringify({"cardIds": card_ids, "untilUtc": until_utc}), func(_parsed): pass, "cards")
func undo_last_review(review_log_id: String, on_done: Callable) -> void:
_enqueue_json("POST", "/reviews/undo", JSON.stringify({"reviewLogId": review_log_id}),
func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo")
func _enqueue_json(method: String, path: String, body: String, on_success: Callable, tag: String) -> void:
misc_queue.append({"method": method, "path": path, "body": body, "on_success": on_success, "tag": tag})
_pump_misc_queue()
func _pump_misc_queue() -> void:
if misc_busy or misc_queue.is_empty() or not AuthState.can_send_authenticated_request():
return
var job: Dictionary = misc_queue.pop_front()
misc_busy = true
var method_map := {
"GET": HTTPClient.METHOD_GET, "POST": HTTPClient.METHOD_POST,
"PUT": HTTPClient.METHOD_PUT, "DELETE": HTTPClient.METHOD_DELETE
}
var error := misc_request.request(
"%s%s" % [api_url, str(job.get("path", ""))],
_headers(),
method_map.get(job.get("method", "GET"), HTTPClient.METHOD_GET),
str(job.get("body", "")))
if error != OK:
misc_busy = false
request_failed.emit(str(job.get("tag", "")), "Request could not be sent. Check your connection.")
_pump_misc_queue()
return
misc_request.set_meta("current_job", job)
func _on_misc_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
misc_busy = false
var job: Dictionary = misc_request.get_meta("current_job", {})
var tag := str(job.get("tag", ""))
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
_pump_misc_queue()
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(tag, "That request failed. Check your connection and try again.")
_pump_misc_queue()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
var on_success: Callable = job.get("on_success", Callable())
if on_success.is_valid():
on_success.call(parsed)
_pump_misc_queue()
# ---------- Account lifecycle / outbox persistence ----------
func _on_auth_changed() -> void:
active_account_key = _account_key()
if AuthState.is_logged_in:
_process_outbox()
elif retry_timer != null:
retry_timer.stop()
func _account_key() -> String:
return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else ""
func _next_account_item_index() -> int:
for index in range(outbox.size()):
if str(outbox[index].get("account", "")) == active_account_key:
return index
return -1
func _active_item_index() -> int:
for index in range(outbox.size()):
if bool(outbox[index].get("active", false)):
return index
return -1
func _load_outbox() -> void:
outbox = []
if not FileAccess.file_exists(OUTBOX_PATH):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH))
if typeof(parsed) != TYPE_ARRAY:
return
for item in parsed:
if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY:
item.erase("active")
outbox.append(item)
func _save_outbox() -> void:
var stored := outbox.duplicate(true)
for item in stored:
item.erase("active")
var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(stored))
func _new_uuid() -> String:
var bytes := Crypto.new().generate_random_bytes(16)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var value := bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]
+4
View File
@@ -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"))
+41
View File
@@ -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
+178
View File
@@ -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
+422
View File
@@ -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
+318
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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":
+33 -1
View File
@@ -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