Wire flashcard CSV/JSON import/export and fix theme/responsive gaps
Import and export were stubbed with status-text placeholders; wire them to the existing backend endpoints with a real file picker (web JS bridge or native FileDialog) and browser/desktop download, plus an import preview with create/update/skip/error counts before commit. Also fix the library and study screens' theme-refresh handlers, which were no-op stubs so cards never restyled on a theme toggle, and add responsive layout handling for narrow viewports.
This commit is contained in:
@@ -75,6 +75,14 @@ create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after a
|
||||
skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and
|
||||
10,000 notes per import.
|
||||
|
||||
The Godot client (`scripts/autoload/flashcard_service.gd`) picks a file, uploads it to the
|
||||
matching preview endpoint as `multipart/form-data`, and shows the create/update/skip/error counts
|
||||
before letting the user commit or cancel. Export streams the response body and saves it client
|
||||
side. File selection and download both branch on `OS.has_feature("web")`: on web, a hidden
|
||||
`<input type="file">`/`FileReader` pair (bridged back into GDScript via
|
||||
`JavaScriptBridge.create_callback`) and an anchor-click data-URL download stand in for a native
|
||||
picker/save dialog; on desktop, a `FileDialog` and the OS downloads folder are used instead.
|
||||
|
||||
## Deployment
|
||||
|
||||
No new secrets. `FlashcardsController`/stores register the same way as the rest of the API
|
||||
|
||||
@@ -19,6 +19,9 @@ signal review_resumed
|
||||
signal notes_loaded(response: Dictionary)
|
||||
signal note_saved(note: Dictionary)
|
||||
signal request_failed(tag: String, message: String)
|
||||
signal export_ready(location: String)
|
||||
signal import_preview_ready(preview: Dictionary)
|
||||
signal import_committed(result: Dictionary)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards"
|
||||
@@ -28,6 +31,8 @@ var api_url := DEFAULT_API_URL
|
||||
var next_request: HTTPRequest
|
||||
var review_request: HTTPRequest
|
||||
var misc_request: HTTPRequest
|
||||
var export_request: HTTPRequest
|
||||
var import_request: HTTPRequest
|
||||
var retry_timer: Timer
|
||||
var loading_next := false
|
||||
var submitting_review := false
|
||||
@@ -36,6 +41,9 @@ var misc_queue: Array = []
|
||||
var outbox: Array = []
|
||||
var active_account_key := ""
|
||||
var is_paused := false
|
||||
var export_busy := false
|
||||
var export_pending: Dictionary = {}
|
||||
var import_busy := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -45,6 +53,8 @@ func _ready() -> void:
|
||||
next_request = _make_request(_on_next_completed)
|
||||
review_request = _make_request(_on_review_completed)
|
||||
misc_request = _make_request(_on_misc_completed)
|
||||
export_request = _make_request(_on_export_completed)
|
||||
import_request = _make_request(_on_import_preview_completed)
|
||||
retry_timer = Timer.new()
|
||||
retry_timer.one_shot = true
|
||||
retry_timer.wait_time = 5.0
|
||||
@@ -271,6 +281,157 @@ func undo_last_review(review_log_id: String, on_done: Callable) -> void:
|
||||
func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo")
|
||||
|
||||
|
||||
# ---------- Export (browser download / desktop file save) ----------
|
||||
|
||||
func export_csv() -> void:
|
||||
_download_export("csv", "text/csv", "flashcards.csv")
|
||||
|
||||
|
||||
func export_json() -> void:
|
||||
_download_export("json", "application/json", "flashcards.json")
|
||||
|
||||
|
||||
func _download_export(format: String, mime: String, filename: String) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
request_failed.emit("export", "Login is required to export flashcards.")
|
||||
return
|
||||
if export_busy:
|
||||
request_failed.emit("export", "An export is already in progress.")
|
||||
return
|
||||
export_busy = true
|
||||
export_pending = {"mime": mime, "filename": filename}
|
||||
var error := export_request.request("%s/export/%s" % [api_url, format], _headers(), HTTPClient.METHOD_GET)
|
||||
if error != OK:
|
||||
export_busy = false
|
||||
export_pending = {}
|
||||
request_failed.emit("export", "The export could not be started. Check your connection and try again.")
|
||||
|
||||
|
||||
func _on_export_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
|
||||
export_busy = false
|
||||
var pending := export_pending
|
||||
export_pending = {}
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
request_failed.emit("export", "The export failed. Check your connection and try again.")
|
||||
return
|
||||
_save_export_file(body.get_string_from_utf8(), str(pending.get("filename", "flashcards.txt")), str(pending.get("mime", "text/plain")))
|
||||
|
||||
|
||||
func _save_export_file(content: String, filename: String, mime: String) -> void:
|
||||
if OS.has_feature("web"):
|
||||
var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer())
|
||||
JavaScriptBridge.eval(
|
||||
"(function(){const a=document.createElement('a');a.href='data:%s;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [mime, encoded, JSON.stringify(filename)],
|
||||
true)
|
||||
export_ready.emit(filename)
|
||||
return
|
||||
var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)
|
||||
var path := directory.path_join(filename)
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
request_failed.emit("export", "The export could not be saved.")
|
||||
return
|
||||
file.store_string(content)
|
||||
export_ready.emit(path)
|
||||
|
||||
|
||||
# ---------- Import (file picker -> preview -> commit) ----------
|
||||
|
||||
func pick_import_file(format: String) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
request_failed.emit("import", "Login is required to import flashcards.")
|
||||
return
|
||||
if import_busy:
|
||||
request_failed.emit("import", "An import is already in progress.")
|
||||
return
|
||||
var accept := ".csv" if format == "csv" else ".json"
|
||||
if OS.has_feature("web"):
|
||||
_pick_file_web(accept, format)
|
||||
else:
|
||||
_pick_file_native(accept, format)
|
||||
|
||||
|
||||
func _pick_file_web(accept: String, format: String) -> void:
|
||||
var callback := JavaScriptBridge.create_callback(_on_web_file_selected.bind(format))
|
||||
JavaScriptBridge.eval(
|
||||
("(function(cb){" +
|
||||
"var input=document.createElement('input');" +
|
||||
"input.type='file';input.accept=%s;input.style.display='none';" +
|
||||
"document.body.appendChild(input);" +
|
||||
"input.addEventListener('change',function(){" +
|
||||
"var file=input.files&&input.files[0];" +
|
||||
"document.body.removeChild(input);" +
|
||||
"if(!file){cb('','');return;}" +
|
||||
"var reader=new FileReader();" +
|
||||
"reader.onload=function(){cb(reader.result,file.name);};" +
|
||||
"reader.onerror=function(){cb('','');};" +
|
||||
"reader.readAsText(file);" +
|
||||
"});" +
|
||||
"input.click();" +
|
||||
"})(%s);") % [JSON.stringify(accept), callback],
|
||||
true)
|
||||
|
||||
|
||||
func _on_web_file_selected(text: String, filename: String, format: String) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
_upload_import_preview(text, filename, format)
|
||||
|
||||
|
||||
func _pick_file_native(accept: String, format: String) -> void:
|
||||
var dialog := FileDialog.new()
|
||||
dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||
dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||
dialog.filters = PackedStringArray(["*%s ; %s files" % [accept, format.to_upper()]])
|
||||
add_child(dialog)
|
||||
dialog.file_selected.connect(func(path: String):
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
dialog.queue_free()
|
||||
_upload_import_preview(text, path.get_file(), format))
|
||||
dialog.canceled.connect(func(): dialog.queue_free())
|
||||
dialog.popup_centered_ratio(0.6)
|
||||
|
||||
|
||||
func _upload_import_preview(text: String, filename: String, format: String) -> void:
|
||||
import_busy = true
|
||||
var boundary := "----GodotFormBoundary%d" % Time.get_ticks_usec()
|
||||
var content_type := "text/csv" if format == "csv" else "application/json"
|
||||
var body := "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n%s\r\n--%s--\r\n" % [
|
||||
boundary, filename, content_type, text, boundary]
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: multipart/form-data; boundary=%s" % boundary,
|
||||
"Authorization: Bearer %s" % AuthState.access_token
|
||||
])
|
||||
var error := import_request.request(
|
||||
"%s/import/%s/preview" % [api_url, format], headers, HTTPClient.METHOD_POST, body)
|
||||
if error != OK:
|
||||
import_busy = false
|
||||
request_failed.emit("import", "The file could not be uploaded. Check your connection and try again.")
|
||||
|
||||
|
||||
func _on_import_preview_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
|
||||
import_busy = 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:
|
||||
request_failed.emit("import", "The file could not be previewed. Check that it matches the expected format and try again.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
request_failed.emit("import", "The import preview response was invalid.")
|
||||
return
|
||||
import_preview_ready.emit(parsed)
|
||||
|
||||
|
||||
func commit_import(import_token: String) -> void:
|
||||
_enqueue_json("POST", "/import/commit", JSON.stringify({"importToken": import_token}),
|
||||
func(parsed): import_committed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "import_commit")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
+213
-16
@@ -22,6 +22,7 @@ var list_container: VBoxContainer
|
||||
var debounce_timer: Timer
|
||||
|
||||
var editor_overlay: Control
|
||||
var editor_panel: PanelContainer
|
||||
var editor_note_id := ""
|
||||
var editor_expected_revision: Variant = null
|
||||
var editor_type_selector: OptionButton
|
||||
@@ -32,6 +33,18 @@ var editor_tags_input: LineEdit
|
||||
var editor_hint_label: Label
|
||||
var editor_error_label: Label
|
||||
|
||||
var export_menu: PanelContainer
|
||||
var import_menu: PanelContainer
|
||||
|
||||
var import_overlay: Control
|
||||
var import_panel: PanelContainer
|
||||
var import_summary_label: Label
|
||||
var import_rows_container: VBoxContainer
|
||||
var import_commit_button: Button
|
||||
var import_pending_token := ""
|
||||
|
||||
var compact_layout := false
|
||||
|
||||
var decks: Array = []
|
||||
var notes: Array = []
|
||||
|
||||
@@ -58,11 +71,17 @@ func _ready() -> void:
|
||||
debounce_timer.timeout.connect(_reload_notes)
|
||||
add_child(debounce_timer)
|
||||
_build_editor_overlay()
|
||||
_build_import_overlay()
|
||||
resized.connect(_update_responsive_layout)
|
||||
call_deferred("_update_responsive_layout")
|
||||
|
||||
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)
|
||||
FlashcardService.export_ready.connect(_on_export_ready)
|
||||
FlashcardService.import_preview_ready.connect(_on_import_preview_ready)
|
||||
FlashcardService.import_committed.connect(_on_import_committed)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status())
|
||||
|
||||
|
||||
@@ -104,14 +123,60 @@ func _build_toolbar() -> void:
|
||||
import_button = Button.new()
|
||||
import_button.text = "Import"
|
||||
import_button.custom_minimum_size = Vector2(0, 44)
|
||||
import_button.pressed.connect(_open_import_dialog)
|
||||
import_button.pressed.connect(func(): _toggle_dropdown(import_menu, import_button))
|
||||
row.add_child(import_button)
|
||||
import_menu = _build_dropdown([
|
||||
["Import CSV...", func():
|
||||
status_label.text = "Choose a file to import..."
|
||||
FlashcardService.pick_import_file("csv")],
|
||||
["Import JSON...", func():
|
||||
status_label.text = "Choose a file to import..."
|
||||
FlashcardService.pick_import_file("json")],
|
||||
])
|
||||
|
||||
export_button = Button.new()
|
||||
export_button.text = "Export"
|
||||
export_button.custom_minimum_size = Vector2(0, 44)
|
||||
export_button.pressed.connect(_export_csv)
|
||||
export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button))
|
||||
row.add_child(export_button)
|
||||
export_menu = _build_dropdown([
|
||||
["Export CSV", func():
|
||||
status_label.text = "Preparing export..."
|
||||
FlashcardService.export_csv()],
|
||||
["Export JSON", func():
|
||||
status_label.text = "Preparing export..."
|
||||
FlashcardService.export_json()],
|
||||
])
|
||||
|
||||
|
||||
func _build_dropdown(items: Array) -> PanelContainer:
|
||||
var menu := PanelContainer.new()
|
||||
menu.visible = false
|
||||
menu.top_level = true
|
||||
menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
add_child(menu)
|
||||
var list := VBoxContainer.new()
|
||||
list.add_theme_constant_override("separation", 2)
|
||||
menu.add_child(list)
|
||||
for item in items:
|
||||
var button := Button.new()
|
||||
button.text = str(item[0])
|
||||
button.custom_minimum_size = Vector2(180, 40)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
var action: Callable = item[1]
|
||||
button.pressed.connect(func():
|
||||
menu.visible = false
|
||||
action.call())
|
||||
list.add_child(button)
|
||||
return menu
|
||||
|
||||
|
||||
func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void:
|
||||
if menu.visible:
|
||||
menu.visible = false
|
||||
return
|
||||
menu.visible = true
|
||||
menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4)
|
||||
|
||||
|
||||
func _build_filters() -> void:
|
||||
@@ -196,7 +261,8 @@ 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()
|
||||
var row := BoxContainer.new()
|
||||
row.vertical = compact_layout
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
card.add_child(row)
|
||||
|
||||
@@ -255,13 +321,13 @@ func _build_editor_overlay() -> void:
|
||||
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)
|
||||
editor_panel = PanelContainer.new()
|
||||
editor_panel.custom_minimum_size = Vector2(620, 560)
|
||||
editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
center.add_child(editor_panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
editor_panel.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
content.add_child(header)
|
||||
@@ -394,7 +460,9 @@ func _on_note_saved(_note: Dictionary) -> void:
|
||||
FlashcardService.fetch_decks()
|
||||
|
||||
|
||||
func _on_request_failed(_tag: String, message: String) -> void:
|
||||
func _on_request_failed(tag: String, message: String) -> void:
|
||||
if tag == "import_commit":
|
||||
import_commit_button.disabled = false
|
||||
editor_error_label.text = message
|
||||
editor_error_label.visible = true
|
||||
status_label.text = message
|
||||
@@ -408,15 +476,144 @@ 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 _on_export_ready(location: String) -> void:
|
||||
status_label.text = "Exported to %s." % location
|
||||
|
||||
|
||||
func _open_import_dialog() -> void:
|
||||
status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)."
|
||||
# ---------- Import preview / commit ----------
|
||||
|
||||
func _build_import_overlay() -> void:
|
||||
import_overlay = Control.new()
|
||||
import_overlay.visible = false
|
||||
import_overlay.top_level = true
|
||||
import_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
import_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(import_overlay)
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.72)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
import_overlay.add_child(dim)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
import_overlay.add_child(center)
|
||||
import_panel = PanelContainer.new()
|
||||
import_panel.custom_minimum_size = Vector2(620, 480)
|
||||
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
center.add_child(import_panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
import_panel.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
content.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Import preview"
|
||||
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_import_overlay)
|
||||
header.add_child(close_button)
|
||||
|
||||
import_summary_label = Label.new()
|
||||
import_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content.add_child(import_summary_label)
|
||||
|
||||
var rows_scroll := ScrollContainer.new()
|
||||
rows_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content.add_child(rows_scroll)
|
||||
import_rows_container = VBoxContainer.new()
|
||||
import_rows_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
import_rows_container.add_theme_constant_override("separation", 4)
|
||||
rows_scroll.add_child(import_rows_container)
|
||||
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 10)
|
||||
content.add_child(actions)
|
||||
var cancel_button := Button.new()
|
||||
cancel_button.text = "Cancel"
|
||||
cancel_button.custom_minimum_size = Vector2(0, 46)
|
||||
cancel_button.pressed.connect(_close_import_overlay)
|
||||
actions.add_child(cancel_button)
|
||||
import_commit_button = Button.new()
|
||||
import_commit_button.text = "Commit import"
|
||||
import_commit_button.custom_minimum_size = Vector2(0, 46)
|
||||
import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
Style.style_primary_button(import_commit_button)
|
||||
import_commit_button.pressed.connect(_commit_pending_import)
|
||||
actions.add_child(import_commit_button)
|
||||
|
||||
|
||||
func _on_import_preview_ready(preview: Dictionary) -> void:
|
||||
import_pending_token = str(preview.get("importToken", ""))
|
||||
var create_count := int(preview.get("createCount", 0))
|
||||
var update_count := int(preview.get("updateCount", 0))
|
||||
var skip_count := int(preview.get("skipCount", 0))
|
||||
var error_count := int(preview.get("errorCount", 0))
|
||||
import_summary_label.text = "%d to create, %d to update, %d to skip, %d row error(s)." % [create_count, update_count, skip_count, error_count]
|
||||
for child in import_rows_container.get_children():
|
||||
child.queue_free()
|
||||
for raw_row in preview.get("rows", []):
|
||||
import_rows_container.add_child(_import_row(raw_row))
|
||||
import_commit_button.disabled = import_pending_token.is_empty() or (create_count + update_count == 0)
|
||||
status_label.text = ""
|
||||
import_overlay.visible = true
|
||||
|
||||
|
||||
func _import_row(row: Dictionary) -> Control:
|
||||
var label := Label.new()
|
||||
var action := str(row.get("action", ""))
|
||||
var error := str(row.get("error", ""))
|
||||
var detail := error if not error.is_empty() else str(row.get("prompt", ""))
|
||||
label.text = "Row %d - %s: %s" % [int(row.get("rowNumber", 0)), action.capitalize(), detail]
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
if action == "error":
|
||||
label.add_theme_color_override("font_color", Style.RED)
|
||||
return label
|
||||
|
||||
|
||||
func _commit_pending_import() -> void:
|
||||
if import_pending_token.is_empty():
|
||||
return
|
||||
import_commit_button.disabled = true
|
||||
FlashcardService.commit_import(import_pending_token)
|
||||
|
||||
|
||||
func _on_import_committed(result: Dictionary) -> void:
|
||||
import_overlay.visible = false
|
||||
import_pending_token = ""
|
||||
status_label.text = "Import complete: %d created, %d updated, %d skipped, %d error(s)." % [
|
||||
int(result.get("created", 0)), int(result.get("updated", 0)), int(result.get("skipped", 0)), int(result.get("errored", 0))]
|
||||
_reload_notes()
|
||||
FlashcardService.fetch_decks()
|
||||
|
||||
|
||||
func _close_import_overlay() -> void:
|
||||
import_overlay.visible = false
|
||||
import_pending_token = ""
|
||||
|
||||
|
||||
# ---------- Theme / responsive ----------
|
||||
|
||||
func _refresh_status() -> void:
|
||||
pass
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
if editor_panel != null:
|
||||
editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if import_panel != null:
|
||||
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if export_menu != null:
|
||||
export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if import_menu != null:
|
||||
import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
_render_notes()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 720.0
|
||||
if compact_layout == use_compact:
|
||||
return
|
||||
compact_layout = use_compact
|
||||
search_input.custom_minimum_size.x = 0.0 if compact_layout else 260.0
|
||||
_render_notes()
|
||||
|
||||
@@ -33,12 +33,15 @@ var grade_row: HBoxContainer
|
||||
var grade_buttons: Dictionary = {}
|
||||
var undo_button: Button
|
||||
var empty_label: Label
|
||||
var compact_layout := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_theme_constant_override("separation", 14)
|
||||
_build()
|
||||
resized.connect(_update_responsive_layout)
|
||||
call_deferred("_update_responsive_layout")
|
||||
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)
|
||||
@@ -98,6 +101,7 @@ func _build() -> void:
|
||||
|
||||
card_panel = PanelContainer.new()
|
||||
card_panel.custom_minimum_size = Vector2(640, 360)
|
||||
card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE))
|
||||
center.add_child(card_panel)
|
||||
var card_content := VBoxContainer.new()
|
||||
card_content.add_theme_constant_override("separation", 18)
|
||||
@@ -155,7 +159,20 @@ func _build() -> void:
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
pass
|
||||
if card_panel == null:
|
||||
return
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
empty_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
if card_panel == null:
|
||||
return
|
||||
compact_layout = size.x < 720.0
|
||||
var available := maxf(size.x - 48.0, 240.0)
|
||||
card_panel.custom_minimum_size.x = minf(640.0, available)
|
||||
card_panel.custom_minimum_size.y = 300.0 if compact_layout else 360.0
|
||||
|
||||
|
||||
func _load_next() -> void:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
extends SceneTree
|
||||
|
||||
# Flashcards previously had no coverage for theme switching or narrow-viewport layout: the
|
||||
# library screen's `_refresh_status()` and the study screen's `refresh_theme_controls()` were
|
||||
# both no-op stubs, and neither screen reacted to its own resize. This drives both screens
|
||||
# through a dark/light toggle and a phone-width resize with no network calls (mirrors
|
||||
# flashcard_study_render_test.gd: content is injected directly rather than fetched).
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var host := Control.new()
|
||||
host.size = Vector2(1280, 800)
|
||||
root.add_child(host)
|
||||
|
||||
var theme_service = root.get_node("UIThemeService")
|
||||
var prior_dark: bool = theme_service.is_dark_mode()
|
||||
|
||||
var Hub := load("res://scripts/ui/flashcards_hub.gd")
|
||||
var hub = Hub.new()
|
||||
hub.size = host.size
|
||||
host.add_child(hub)
|
||||
await process_frame
|
||||
|
||||
hub.notes = [{"id": "note-1", "type": "basic", "prompt": "2 + 2", "answer": "4", "tags": []}]
|
||||
|
||||
theme_service.set_dark_mode(true)
|
||||
hub._render_notes()
|
||||
await process_frame
|
||||
var dark_card := hub.list_container.get_child(0) as PanelContainer
|
||||
var dark_box := dark_card.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(dark_box.bg_color.is_equal_approx(Brand.content_card_box(true, false, Brand.ROYAL_BLUE).bg_color),
|
||||
"flashcard note cards should use the dark surface while dark mode is active")
|
||||
|
||||
theme_service.set_dark_mode(false)
|
||||
await process_frame
|
||||
var light_card := hub.list_container.get_child(0) as PanelContainer
|
||||
var light_box := light_card.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(light_box.bg_color.is_equal_approx(Brand.content_card_box(false, false, Brand.ROYAL_BLUE).bg_color),
|
||||
"toggling the app theme should rebuild flashcard note cards with the light surface (the library screen's theme_changed handler was previously a no-op)")
|
||||
var light_menu_box := hub.export_menu.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(light_menu_box.bg_color.is_equal_approx(Brand.panel_box(false).bg_color),
|
||||
"the export menu panel should also restyle to the light surface after a theme toggle")
|
||||
|
||||
theme_service.set_dark_mode(prior_dark)
|
||||
|
||||
hub.size = Vector2(360, 800)
|
||||
hub._update_responsive_layout()
|
||||
await process_frame
|
||||
_assert(hub.compact_layout, "the flashcards library should detect a phone-width viewport")
|
||||
var compact_card := hub.list_container.get_child(0) as PanelContainer
|
||||
var compact_row := compact_card.get_child(0) as BoxContainer
|
||||
_assert(compact_row.vertical, "phone-width flashcard rows should stack their content above the edit/archive actions instead of clipping")
|
||||
_assert(compact_card.get_combined_minimum_size().x <= hub.size.x,
|
||||
"a phone-width flashcard row should fit inside the viewport (minimum %.1f px)" % compact_card.get_combined_minimum_size().x)
|
||||
|
||||
hub.size = Vector2(1280, 800)
|
||||
hub._update_responsive_layout()
|
||||
await process_frame
|
||||
_assert(not hub.compact_layout, "a wide viewport should restore the flashcards library's desktop row layout")
|
||||
var wide_row := (hub.list_container.get_child(0) as PanelContainer).get_child(0) as BoxContainer
|
||||
_assert(not wide_row.vertical, "a wide viewport should lay flashcard row content out horizontally again")
|
||||
|
||||
hub.free()
|
||||
|
||||
var Study := load("res://scripts/ui/flashcards_study.gd")
|
||||
var study = Study.new()
|
||||
study._build()
|
||||
|
||||
theme_service.set_dark_mode(true)
|
||||
study.refresh_theme_controls()
|
||||
var dark_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(dark_study_box.bg_color.is_equal_approx(Brand.content_card_box(true, true, Brand.ROYAL_BLUE).bg_color),
|
||||
"the study card should use the dark active-card surface while dark mode is active")
|
||||
|
||||
theme_service.set_dark_mode(false)
|
||||
study.refresh_theme_controls()
|
||||
var light_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(light_study_box.bg_color.is_equal_approx(Brand.content_card_box(false, true, Brand.ROYAL_BLUE).bg_color),
|
||||
"toggling the app theme should restyle the study card to the light surface (refresh_theme_controls was previously a no-op)")
|
||||
_assert(light_study_box.border_color.is_equal_approx(Brand.ROYAL_BLUE),
|
||||
"the study card should keep its accent border after a theme refresh")
|
||||
|
||||
theme_service.set_dark_mode(prior_dark)
|
||||
|
||||
study.size = Vector2(360, 720)
|
||||
study._update_responsive_layout()
|
||||
_assert(study.compact_layout, "the study screen should detect a phone-width viewport")
|
||||
_assert(study.card_panel.custom_minimum_size.x <= study.size.x,
|
||||
"the study card should shrink to fit a phone-width viewport instead of overflowing (minimum %.1f px on a %.1f px viewport)" % [study.card_panel.custom_minimum_size.x, study.size.x])
|
||||
|
||||
study.size = Vector2(1280, 720)
|
||||
study._update_responsive_layout()
|
||||
_assert(not study.compact_layout, "a wide viewport should restore the study screen's desktop layout")
|
||||
_assert(is_equal_approx(study.card_panel.custom_minimum_size.x, 640.0),
|
||||
"a wide viewport should restore the study card's full 640px width")
|
||||
|
||||
study.free()
|
||||
host.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
Reference in New Issue
Block a user