Study card: label the answer and "key takeaway" as distinct sections
with a real visible divider (previously an all-but-invisible
HSeparator) instead of one undifferentiated block of text. Also fixes
a real gap where a cloze card's extra field was silently dropped,
since only basic notes combined answer+extra into one label. Bigger
card, gradient Show-Answer button matching the app's signature CTA
style instead of a duller flat teal, and the Undo button now hides
itself when there's nothing to undo instead of sitting there disabled
looking identical to a real action.
Library: colored type badge per card (Basic/Cloze) instead of plain
gray text, more breathing room inside each row, Study promoted to the
same gradient CTA style, and Edit/Archive given a real secondary
button treatment instead of default theme buttons. Note editor fields
renamed ("Back / Extra takeaway" + a separate "Extra" field was
genuinely confusing) and now show which deck a card is being saved to.
403 lines
13 KiB
GDScript
403 lines
13 KiB
GDScript
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: ColorRect
|
|
var answer_caption: Label
|
|
var back_label: Label
|
|
var extra_caption: Label
|
|
var extra_label: Label
|
|
var reveal_button: Button
|
|
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)
|
|
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
|
|
last_client_review_id = ""
|
|
last_card_id = ""
|
|
undo_button.visible = 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(720, 420)
|
|
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", 20)
|
|
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", 25)
|
|
card_content.add_child(front_label)
|
|
|
|
divider = ColorRect.new()
|
|
divider.custom_minimum_size = Vector2(0, 2)
|
|
divider.visible = false
|
|
card_content.add_child(divider)
|
|
|
|
var answer_block := VBoxContainer.new()
|
|
answer_block.add_theme_constant_override("separation", 4)
|
|
card_content.add_child(answer_block)
|
|
answer_caption = _caption_label("ANSWER")
|
|
answer_block.add_child(answer_caption)
|
|
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", 19)
|
|
back_label.visible = false
|
|
answer_block.add_child(back_label)
|
|
|
|
var extra_block := VBoxContainer.new()
|
|
extra_block.add_theme_constant_override("separation", 4)
|
|
card_content.add_child(extra_block)
|
|
extra_caption = _caption_label("KEY TAKEAWAY")
|
|
extra_block.add_child(extra_caption)
|
|
extra_label = Label.new()
|
|
extra_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
extra_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
extra_label.add_theme_font_size_override("font_size", 15)
|
|
extra_label.visible = false
|
|
extra_block.add_child(extra_label)
|
|
|
|
reveal_button = Button.new()
|
|
reveal_button.text = "Show Answer (Space)"
|
|
reveal_button.custom_minimum_size = Vector2(0, 52)
|
|
_style_gradient_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, 36)
|
|
undo_button.visible = false
|
|
undo_button.pressed.connect(_undo)
|
|
root.add_child(undo_button)
|
|
_style_ghost_button(undo_button, UIThemeService.is_dark_mode())
|
|
|
|
|
|
func _caption_label(text: String) -> Label:
|
|
var label := Label.new()
|
|
label.text = text
|
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
label.add_theme_font_size_override("font_size", 12)
|
|
label.visible = false
|
|
return label
|
|
|
|
|
|
func _style_gradient_button(button: Button) -> void:
|
|
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
|
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
|
|
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
|
|
button.add_theme_color_override("font_color", Color.WHITE)
|
|
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
|
|
|
|
|
func _style_ghost_button(button: Button, dark: bool) -> void:
|
|
var empty := StyleBoxEmpty.new()
|
|
button.add_theme_stylebox_override("normal", empty)
|
|
button.add_theme_stylebox_override("hover", empty)
|
|
button.add_theme_stylebox_override("pressed", empty)
|
|
button.add_theme_stylebox_override("focus", empty)
|
|
button.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
button.add_theme_color_override("font_hover_color", Brand.text_color(dark))
|
|
|
|
|
|
func refresh_theme_controls() -> void:
|
|
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))
|
|
divider.color = Brand.ROYAL_BLUE if dark else Brand.PURPLE
|
|
answer_caption.add_theme_color_override("font_color", Brand.ROYAL_BLUE if dark else Brand.PURPLE)
|
|
extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
extra_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
_style_ghost_button(undo_button, 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(720.0, available)
|
|
card_panel.custom_minimum_size.y = 340.0 if compact_layout else 420.0
|
|
|
|
|
|
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..."
|
|
_hide_answer()
|
|
reveal_button.visible = false
|
|
grade_row.visible = false
|
|
|
|
|
|
func _render_empty(card: Dictionary) -> void:
|
|
empty_label.visible = true
|
|
front_label.visible = false
|
|
_hide_answer()
|
|
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 _hide_answer() -> void:
|
|
divider.visible = false
|
|
answer_caption.visible = false
|
|
back_label.visible = false
|
|
extra_caption.visible = false
|
|
extra_label.visible = false
|
|
|
|
|
|
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
|
|
_hide_answer()
|
|
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:
|
|
back_label.text = str(current_note.get("answer", ""))
|
|
var extra := str(current_note.get("extra", ""))
|
|
divider.visible = true
|
|
answer_caption.visible = true
|
|
back_label.visible = true
|
|
extra_caption.visible = not extra.is_empty()
|
|
extra_label.visible = not extra.is_empty()
|
|
extra_label.text = extra
|
|
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.visible = true
|
|
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 = ""
|
|
undo_button.visible = false
|
|
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 ""
|