diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd index 048d616..ea62ff1 100644 --- a/scripts/ui/flashcards_hub.gd +++ b/scripts/ui/flashcards_hub.gd @@ -6,6 +6,13 @@ const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") var main +var stats_row: HFlowContainer +var stat_due_value: Label +var stat_new_value: Label +var stat_decks_value: Label +var list_card: PanelContainer +var list_section_title: Label + var deck_selector: OptionButton var deck_ids: Array = [] var new_deck_button: Button @@ -56,17 +63,28 @@ func _ready() -> void: size_flags_vertical = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 14) _build_dropdown_backdrop() + _build_stats() _build_toolbar() _build_filters() status_label = Label.new() status_label.add_theme_font_override("font", Brand.font) add_child(status_label) + + list_card = PanelContainer.new() + list_card.size_flags_vertical = Control.SIZE_EXPAND_FILL + list_card.add_theme_stylebox_override("panel", _list_card_box(UIThemeService.is_dark_mode())) + add_child(list_card) + var list_card_content := VBoxContainer.new() + list_card_content.add_theme_constant_override("separation", 10) + list_card.add_child(list_card_content) + list_section_title = _section_title("Your flashcards") + list_card_content.add_child(list_section_title) list_scroll = ScrollContainer.new() list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - add_child(list_scroll) + list_card_content.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_container.add_theme_constant_override("separation", 14) list_scroll.add_child(list_container) debounce_timer = Timer.new() debounce_timer.one_shot = true @@ -93,6 +111,79 @@ func on_show(_payload: Variant = null) -> void: _reload_notes() +func _build_stats() -> void: + var previous_due := stat_due_value.text if stat_due_value != null else "--" + var previous_new := stat_new_value.text if stat_new_value != null else "--" + var previous_decks := stat_decks_value.text if stat_decks_value != null else "--" + if stats_row == null: + stats_row = HFlowContainer.new() + stats_row.add_theme_constant_override("separation", 24) + add_child(stats_row) + move_child(stats_row, 0) + for child in stats_row.get_children(): + child.queue_free() + stat_due_value = _add_stat(stats_row, "Due today", "clock-3", Brand.AMBER) + stat_new_value = _add_stat(stats_row, "New available", "clipboard-plus", Brand.ROYAL_BLUE) + stat_decks_value = _add_stat(stats_row, "Decks", "clipboard-list", Brand.PURPLE) + stat_due_value.text = previous_due + stat_new_value.text = previous_new + stat_decks_value.text = previous_decks + + +func _add_stat(row: HFlowContainer, title: String, icon_name: String, accent: Color) -> Label: + var dark := UIThemeService.is_dark_mode() + var cell := HBoxContainer.new() + cell.custom_minimum_size = Vector2(200, 56) + cell.add_theme_constant_override("separation", 12) + row.add_child(cell) + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(48, 48) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name) + icon.custom_minimum_size = Vector2(22, 22) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_panel.add_child(icon) + cell.add_child(icon_panel) + var copy := VBoxContainer.new() + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + var caption := Label.new() + caption.text = title + caption.add_theme_font_size_override("font_size", 13) + caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + copy.add_child(caption) + var value := Label.new() + value.text = "--" + value.add_theme_font_size_override("font_size", 24) + value.add_theme_color_override("font_color", Brand.text_color(dark)) + copy.add_child(value) + cell.add_child(copy) + return value + + +func _list_card_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081426") if dark else Color.WHITE + box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 16 + box.content_margin_bottom = 16 + return box + + +func _section_title(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 16) + label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode())) + return label + + func _build_toolbar() -> void: var row := HFlowContainer.new() row.add_theme_constant_override("separation", 10) @@ -113,11 +204,7 @@ func _build_toolbar() -> void: study_button = Button.new() study_button.text = "Study" study_button.custom_minimum_size = Vector2(110, 44) - study_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) - study_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) - study_button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) - study_button.add_theme_color_override("font_color", Color.WHITE) - study_button.add_theme_color_override("font_hover_color", Color.WHITE) + _style_primary_button(study_button) study_button.pressed.connect(_start_study) row.add_child(study_button) @@ -156,6 +243,19 @@ func _build_toolbar() -> void: ]) +func _style_primary_button(button: Button) -> void: + var normal := StyleBoxFlat.new() + normal.bg_color = Brand.ROYAL_BLUE + normal.set_corner_radius_all(6) + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color("3474ee") + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", normal) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + + func _build_dropdown_backdrop() -> void: dropdown_backdrop = Control.new() dropdown_backdrop.visible = false @@ -250,15 +350,22 @@ func _on_decks_changed(loaded_decks: Array) -> void: deck_selector.clear() deck_selector.add_item("All decks") deck_ids = [""] + var total_due := 0 + var total_new := 0 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_selector.add_item(str(deck.get("name", "Deck"))) deck_ids.append(str(deck.get("id", ""))) + total_due += int(summary.get("due", 0)) + total_new += int(summary.get("new", 0)) if previous_selection != null: var restored := deck_ids.find(previous_selection) if restored >= 0: deck_selector.select(restored) + if stat_due_value != null: + stat_due_value.text = str(total_due) + stat_new_value.text = str(total_new) + stat_decks_value.text = str(decks.size()) func _selected_deck_id() -> String: @@ -288,35 +395,56 @@ func _on_notes_loaded(response: Dictionary) -> void: 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)) + var dark := UIThemeService.is_dark_mode() + if list_section_title != null: + list_section_title.text = "Your flashcards (%d)" % notes.size() + status_label.text = "" + if notes.is_empty(): + var empty_label := Label.new() + empty_label.text = "No flashcards match these filters yet." + empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + list_container.add_child(empty_label) + return + for index in notes.size(): + if index > 0: + list_container.add_child(_row_separator(dark)) + list_container.add_child(_note_row(notes[index])) func _note_row(note: Dictionary) -> Control: var dark := UIThemeService.is_dark_mode() - var card := PanelContainer.new() - var card_box := Brand.content_card_box(dark, false, Brand.ROYAL_BLUE) - card_box.content_margin_left = 16 - card_box.content_margin_right = 16 - card_box.content_margin_top = 14 - card_box.content_margin_bottom = 14 - card.add_theme_stylebox_override("panel", card_box) + var type_str := str(note.get("type", "basic")) + var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE + var row := BoxContainer.new() row.vertical = compact_layout - row.add_theme_constant_override("separation", 12) - card.add_child(row) + row.add_theme_constant_override("separation", 14) + + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(40, 40) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/%s.svg" % ("notebook-pen" if type_str == "cloze" else "clipboard-list")) + icon.custom_minimum_size = Vector2(18, 18) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_panel.add_child(icon) + row.add_child(icon_panel) var content := VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content.add_theme_constant_override("separation", 6) + content.add_theme_constant_override("separation", 4) row.add_child(content) - var type_str := str(note.get("type", "basic")) var header_row := HBoxContainer.new() header_row.add_theme_constant_override("separation", 8) content.add_child(header_row) - header_row.add_child(_type_badge(type_str, dark)) + var type_label := Label.new() + type_label.text = type_str.capitalize() + type_label.add_theme_font_size_override("font_size", 12) + type_label.add_theme_color_override("font_color", accent) + header_row.add_child(type_label) var tags: Array = note.get("tags", []) if tags.size() > 0: var tags_label := Label.new() @@ -333,13 +461,17 @@ func _note_row(note: Dictionary) -> Control: prompt_label.add_theme_font_override("font", Brand.font) content.add_child(prompt_label) + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 8) + row.add_child(actions) + var edit_button := Button.new() edit_button.text = "Edit" edit_button.custom_minimum_size = Vector2(70, 40) edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) - row.add_child(edit_button) + actions.add_child(edit_button) var archive_button := Button.new() archive_button.text = "Archive" @@ -351,30 +483,16 @@ func _note_row(note: Dictionary) -> Control: FlashcardService.archive_notes([str(note.get("id", ""))], true) await get_tree().create_timer(0.3).timeout _reload_notes()) - row.add_child(archive_button) + actions.add_child(archive_button) - return card + return row -func _type_badge(type_str: String, dark: bool) -> PanelContainer: - var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE - var badge := PanelContainer.new() - var box := StyleBoxFlat.new() - box.bg_color = Color(accent, 0.16 if dark else 0.12) - box.border_color = accent - box.set_border_width_all(1) - box.set_corner_radius_all(8) - box.content_margin_left = 8 - box.content_margin_right = 8 - box.content_margin_top = 2 - box.content_margin_bottom = 2 - badge.add_theme_stylebox_override("panel", box) - var label := Label.new() - label.text = type_str.capitalize() - label.add_theme_font_size_override("font_size", 11) - label.add_theme_color_override("font_color", accent) - badge.add_child(label) - return badge +func _row_separator(dark: bool) -> ColorRect: + var line := ColorRect.new() + line.custom_minimum_size = Vector2(0, 1) + line.color = Color("1c2b49") if dark else Brand.LIGHT_BORDER + return line # ---------- Note editor ---------- @@ -464,11 +582,7 @@ func _build_editor_overlay() -> void: var save_button := Button.new() save_button.text = "Save" save_button.custom_minimum_size = Vector2(0, 46) - save_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) - save_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) - save_button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) - save_button.add_theme_color_override("font_color", Color.WHITE) - save_button.add_theme_color_override("font_hover_color", Color.WHITE) + _style_primary_button(save_button) save_button.pressed.connect(_save_editor) content.add_child(save_button) @@ -635,7 +749,7 @@ func _build_import_overlay() -> void: 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) + _style_primary_button(import_commit_button) import_commit_button.pressed.connect(_commit_pending_import) actions.add_child(import_commit_button) @@ -703,6 +817,11 @@ func _refresh_status() -> void: 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)) + if list_card != null: + list_card.add_theme_stylebox_override("panel", _list_card_box(dark)) + if list_section_title != null: + list_section_title.add_theme_color_override("font_color", Brand.text_color(dark)) + _build_stats() _render_notes() diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd index 234c25b..297e59e 100644 --- a/tests/flashcard_theme_responsive_test.gd +++ b/tests/flashcard_theme_responsive_test.gd @@ -34,17 +34,23 @@ func _run() -> void: 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") + 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_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_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") @@ -55,17 +61,16 @@ func _run() -> void: 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 + 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_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) + _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 PanelContainer).get_child(0) as BoxContainer + 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()