Study screen now matches the provided mockup's layout: a "Current Deck" panel with a circular mastery-progress ring, and a "Spaced Repetition" panel (New/Due/Learning/Review counts, streak, daily goal) on either side of the card. Kept the app's real global sidebar rather than the mockup's own nav, per direction. Mastery/Streak/Daily Goal weren't tracked anywhere, so this adds real backend support rather than showing fabricated numbers: - Mastered = graduated to review state AND FSRS stability >= 21 days (FlashcardScheduleStore.MasteryStabilityDaysThreshold), stricter than the plain Review-state count - CurrentStreakDays = consecutive UTC days with a review, computed from review-log timestamps in the new pure FlashcardScheduling .ComputeStreakDays (unit tested, habit-streak semantics: doesn't reset to 0 the instant a new day starts before today's first review) - DailyGoal/ReviewsToday reuse the account's existing ReviewsPerDay setting rather than inventing a new one Also removes the exit "X" button in favor of the same Escape/pause menu the question-player flow uses (main.gd's shared app_menu, with an "Exit Study Session" action), and adds a new circular_progress.gd reusable ring control plus a real "Options" menu (bury/suspend the current card) wired to schedule endpoints that had no UI before.
153 lines
7.4 KiB
GDScript
153 lines
7.4 KiB
GDScript
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_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat
|
|
_assert(dark_list_box.bg_color.is_equal_approx(Color("081426")),
|
|
"the flashcards list card should use the dark surface while dark mode is active")
|
|
var dark_row := hub.list_container.get_child(0) as BoxContainer
|
|
var dark_icon_box := (dark_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat
|
|
_assert(dark_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(true, Brand.ROYAL_BLUE, 20).bg_color),
|
|
"a basic card's type icon should use its dark accent styling")
|
|
|
|
theme_service.set_dark_mode(false)
|
|
await process_frame
|
|
var light_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat
|
|
_assert(light_list_box.bg_color.is_equal_approx(Color.WHITE),
|
|
"toggling the app theme should rebuild the flashcards list card with the light surface (the library screen's theme_changed handler was previously a no-op)")
|
|
var light_row := hub.list_container.get_child(0) as BoxContainer
|
|
var light_icon_box := (light_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat
|
|
_assert(light_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(false, Brand.ROYAL_BLUE, 20).bg_color),
|
|
"a basic card's type icon should use its light accent styling")
|
|
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_row := hub.list_container.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_row.get_combined_minimum_size().x <= hub.size.x,
|
|
"a phone-width flashcard row should fit inside the viewport (minimum %.1f px)" % compact_row.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 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, 720.0),
|
|
"a wide viewport should restore the study card's full 720px width")
|
|
_assert(study.deck_panel.visible and study.stats_panel.visible,
|
|
"a wide viewport should show the current-deck and spaced-repetition side panels")
|
|
|
|
study.size = Vector2(360, 720)
|
|
study._update_responsive_layout()
|
|
_assert(not study.deck_panel.visible and not study.stats_panel.visible,
|
|
"a phone-width viewport should hide the side panels rather than squeeze a 3-column layout into no room")
|
|
|
|
study.deck_id = "deck-1"
|
|
study.decks = [
|
|
{"deck": {"id": "deck-1", "name": "Nephrology"}, "total": 150, "mastered": 48},
|
|
{"deck": {"id": "deck-2", "name": "Cardiology"}, "total": 40, "mastered": 40},
|
|
]
|
|
study._refresh_deck_panel()
|
|
_assert(study.deck_name_label.text == "Nephrology", "the deck panel should show the currently-studied deck's name, not just the first one")
|
|
_assert(study.deck_mastered_label.text == "48 / 150 cards mastered", "the deck panel should report mastered/total for the current deck only")
|
|
|
|
study.deck_id = ""
|
|
study._refresh_deck_panel()
|
|
_assert(study.deck_name_label.text == "All decks", "studying with no deck filter should label the panel \"All decks\"")
|
|
_assert(study.deck_mastered_label.text == "88 / 190 cards mastered", "\"All decks\" should sum mastered/total across every deck")
|
|
|
|
study.latest_counts = {
|
|
"due": 5, "newAvailable": 15, "learning": 48, "review": 132,
|
|
"currentStreakDays": 1, "reviewsToday": 25, "dailyGoal": 50
|
|
}
|
|
study._refresh_stats_panel()
|
|
_assert(study.stats_due_label.text == "5" and study.stats_learning_label.text == "48" and study.stats_review_label.text == "132",
|
|
"the spaced-repetition panel should reflect the latest due/learning/review counts")
|
|
_assert(study.streak_label.text == "1 day", "a streak of exactly one day should use the singular form")
|
|
_assert(study.daily_goal_label.text == "25 / 50" and is_equal_approx(study.daily_goal_bar.value, 25.0),
|
|
"the daily goal panel should show today's review count against the account's configured goal")
|
|
|
|
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)
|