feat(app): a card browser to search, filter, sort and change many cards at once

Browse beside the flashcards' tab opens the studyset's cards over the
tabs (with everything inside it when it has sub-studysets), laid out as
the older design had it:
- a grid of tiles (each card's two sides) or one card at a time beside
  the list (on a phone above a strip of them, swiped through), as last
  left for the studyset on this device;
- a search over front, back, reading, meanings, hints, explanation and
  tag names, katakana found as hiragana and kana by romaji;
- sorting by own order, newest, oldest, A–Z (words by reading), due
  first; a filter sheet by kind, tag and state (new, learning, due,
  buried, reported);
- choosing cards (a toggle; shift-click or long-press for a range,
  select all) to move into another or a new studyset, add or remove
  tags, start afresh, or delete with Undo; the actions in the row on
  wide windows, in a bar at the foot below.

Moving cards between studysets is new in the card repository and its
gateway (studysetId and position written, named columns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 14:21:01 +02:00
commit 42c57bd40a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 4436 additions and 19 deletions

View file

@ -98,7 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. - **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.

View file

@ -1181,5 +1181,83 @@
"pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.", "pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.",
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.", "pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
"pomodoroTopBarSetUp": "Einrichten", "pomodoroTopBarSetUp": "Einrichten",
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen" "pomodoroTopBarDismiss": "Nicht mehr vorschlagen",
"cardBrowserTitle": "Karteikarten",
"cardBrowserTitleWithName": "{title} – Karteikarten",
"cardBrowserTitleSubtree": "{title} (mit Unter-Lernsets)",
"cardBrowserBrowse": "Durchsuchen",
"cardBrowserBrowseTooltip": "Karteikarten durchsuchen",
"cardBrowserStudy": "Lernen",
"cardBrowserEditor": "Editor",
"cardBrowserMore": "Mehr",
"cardBrowserImport": "Karteikarten importieren",
"cardBrowserGrid": "Raster",
"cardBrowserDetail": "Detail",
"cardBrowserSearch": "Suchen",
"cardBrowserSort": "Sortieren",
"cardBrowserFilter": "Filtern",
"cardBrowserSelect": "Auswählen",
"cardBrowserSortManual": "Eigene Reihenfolge",
"cardBrowserSortNewest": "Neueste zuerst",
"cardBrowserSortOldest": "Älteste zuerst",
"cardBrowserSortAlphabetical": "A–Z (Vorderseite)",
"cardBrowserSortDue": "Fällige zuerst",
"cardBrowserSearchHint": "Karteikarten durchsuchen",
"cardBrowserCloseSearch": "Suche schließen",
"cardBrowserClearQuery": "Suche leeren",
"cardBrowserFilterTitle": "Karteikarten filtern",
"cardBrowserFilterType": "Art",
"cardBrowserTypeAll": "Alle Karten",
"cardBrowserTypeFrontBack": "Nur Frage und Antwort",
"cardBrowserTypeMultipleChoice": "Nur Multiple Choice",
"cardBrowserTypeVocabulary": "Nur Wörter",
"cardBrowserTypeMatching": "Nur Zuordnungen",
"cardBrowserTypeMap": "Nur Karten",
"cardBrowserFilterTag": "Tag",
"cardBrowserAllTags": "Alle Tags",
"cardBrowserFilterStatus": "Stand",
"cardBrowserStatusNew": "Neu",
"cardBrowserStatusLearning": "Im Lernen",
"cardBrowserStatusDue": "Fällig",
"cardBrowserStatusBuried": "Zurückgestellt",
"cardBrowserStatusReported": "Gemeldet",
"cardBrowserClearAll": "Alle zurücksetzen",
"cardBrowserApply": "Anwenden",
"cardBrowserEmptyTitle": "Noch keine Karteikarten",
"cardBrowserEmptyHint": "Erstelle welche, um sie hier zu durchsuchen.",
"cardBrowserCreate": "Karteikarten erstellen",
"cardBrowserNoMatches": "Keine Karteikarte passt",
"cardBrowserNoMatchesHint": "Versuche eine andere Suche oder einen anderen Filter.",
"cardBrowserClearFilters": "Suche und Filter zurücksetzen",
"cardBrowserRetry": "Erneut versuchen",
"cardBrowserPrevious": "Vorige",
"cardBrowserNext": "Nächste",
"cardBrowserBuried": "Zurückgestellt",
"cardBrowserSelectAll": "Alle auswählen",
"cardBrowserDeselectAll": "Auswahl aufheben",
"cardBrowserSelected": "{count} ausgewählt",
"cardBrowserRangeHintTouch": "Halte eine andere Karte gedrückt, um alles dazwischen auszuwählen.",
"cardBrowserRangeHintPointer": "Klicke mit gedrückter Umschalttaste auf eine andere Karte, um alles dazwischen auszuwählen.",
"cardBrowserMove": "Verschieben",
"cardBrowserAddTags": "Tags hinzufügen",
"cardBrowserRemoveTags": "Tags entfernen",
"cardBrowserResetProgress": "Fortschritt zurücksetzen",
"cardBrowserDelete": "Löschen",
"cardBrowserMoreActions": "Weitere Aktionen",
"cardBrowserDone": "Fertig",
"cardBrowserDeleted": "{count, plural, =1{1 Karteikarte gelöscht} other{{count} Karteikarten gelöscht}}",
"cardBrowserMoveTitle": "{count, plural, =1{1 Karteikarte verschieben nach} other{{count} Karteikarten verschieben nach}}",
"cardBrowserMoved": "{count, plural, =1{1 Karteikarte nach „{title}“ verschoben} other{{count} Karteikarten nach „{title}“ verschoben}}",
"cardBrowserAddTagsTitle": "{count, plural, =1{Tags zu 1 Karteikarte hinzufügen} other{Tags zu {count} Karteikarten hinzufügen}}",
"cardBrowserAddTo": "{count, plural, =1{Zu 1 Karteikarte hinzufügen} other{Zu {count} Karteikarten hinzufügen}}",
"cardBrowserRemoveTagsTitle": "{count, plural, =1{Tags von 1 Karteikarte entfernen} other{Tags von {count} Karteikarten entfernen}}",
"cardBrowserRemoveFrom": "{count, plural, =1{Von 1 Karteikarte entfernen} other{Von {count} Karteikarten entfernen}}",
"cardBrowserNoTags": "Noch keine Tags. Erstelle Tags im Editor einer Karteikarte und weise sie dann hier zu.",
"cardBrowserNoTagsOnCards": "Die ausgewählten Karteikarten haben keine Tags.",
"cardBrowserTagsAdded": "{tags, plural, =1{1 Tag zu {count, plural, =1{1 Karteikarte} other{{count} Karteikarten}} hinzugefügt} other{{tags} Tags zu {count, plural, =1{1 Karteikarte} other{{count} Karteikarten}} hinzugefügt}}",
"cardBrowserTagsRemoved": "{tags, plural, =1{1 Tag von {count, plural, =1{1 Karteikarte} other{{count} Karteikarten}} entfernt} other{{tags} Tags von {count, plural, =1{1 Karteikarte} other{{count} Karteikarten}} entfernt}}",
"cardBrowserResetTitle": "{count, plural, =1{1 Karteikarte neu beginnen?} other{{count} Karteikarten neu beginnen?}}",
"cardBrowserResetMessage": "Sie gelten wieder als neu. Ihre Wiederholungen bleiben in deinem Verlauf und deinen Serien.",
"cardBrowserResetConfirm": "Zurücksetzen",
"cardBrowserReset": "{count, plural, =1{1 Karteikarte beginnt neu} other{{count} Karteikarten beginnen neu}}"
} }

View file

@ -1393,5 +1393,97 @@
"pathImportMalformedSource": "a source must be an object or text, and its parts text.", "pathImportMalformedSource": "a source must be an object or text, and its parts text.",
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.", "pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
"pomodoroTopBarSetUp": "Set up", "pomodoroTopBarSetUp": "Set up",
"pomodoroTopBarDismiss": "Don't suggest again" "pomodoroTopBarDismiss": "Don't suggest again",
"cardBrowserTitle": "Flashcards",
"cardBrowserTitleWithName": "{title} — flashcards",
"@cardBrowserTitleWithName": {"placeholders": {"title": {"type": "String"}}},
"cardBrowserTitleSubtree": "{title} (incl. sub-studysets)",
"@cardBrowserTitleSubtree": {"placeholders": {"title": {"type": "String"}}},
"cardBrowserBrowse": "Browse",
"cardBrowserBrowseTooltip": "Browse flashcards",
"cardBrowserStudy": "Study",
"cardBrowserEditor": "Editor",
"cardBrowserMore": "More",
"cardBrowserImport": "Import flashcards",
"cardBrowserGrid": "Grid",
"cardBrowserDetail": "Detail",
"cardBrowserSearch": "Search",
"cardBrowserSort": "Sort",
"cardBrowserFilter": "Filter",
"cardBrowserSelect": "Select",
"cardBrowserSortManual": "Manual order",
"cardBrowserSortNewest": "Newest first",
"cardBrowserSortOldest": "Oldest first",
"cardBrowserSortAlphabetical": "A–Z (front)",
"cardBrowserSortDue": "Due first",
"cardBrowserSearchHint": "Search flashcards",
"cardBrowserCloseSearch": "Close search",
"cardBrowserClearQuery": "Clear query",
"cardBrowserFilterTitle": "Filter flashcards",
"cardBrowserFilterType": "Type",
"cardBrowserTypeAll": "All cards",
"cardBrowserTypeFrontBack": "Q&A only",
"cardBrowserTypeMultipleChoice": "Multiple-choice only",
"cardBrowserTypeVocabulary": "Words only",
"cardBrowserTypeMatching": "Matching only",
"cardBrowserTypeMap": "Maps only",
"cardBrowserFilterTag": "Tag",
"cardBrowserAllTags": "All tags",
"cardBrowserFilterStatus": "Status",
"cardBrowserStatusNew": "New",
"cardBrowserStatusLearning": "Learning",
"cardBrowserStatusDue": "Due",
"cardBrowserStatusBuried": "Buried",
"cardBrowserStatusReported": "Reported",
"cardBrowserClearAll": "Clear all",
"cardBrowserApply": "Apply",
"cardBrowserEmptyTitle": "No flashcards yet",
"cardBrowserEmptyHint": "Create some to browse them here.",
"cardBrowserCreate": "Create flashcards",
"cardBrowserNoMatches": "No flashcards match",
"cardBrowserNoMatchesHint": "Try a different search or filter.",
"cardBrowserClearFilters": "Clear search & filters",
"cardBrowserRetry": "Retry",
"cardBrowserPrevious": "Previous",
"cardBrowserNext": "Next",
"cardBrowserBuried": "Buried",
"cardBrowserSelectAll": "Select all",
"cardBrowserDeselectAll": "Deselect all",
"cardBrowserSelected": "{count} selected",
"@cardBrowserSelected": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserRangeHintTouch": "Long-press another card to select everything in between.",
"cardBrowserRangeHintPointer": "Shift-click another card to select everything in between.",
"cardBrowserMove": "Move",
"cardBrowserAddTags": "Add tags",
"cardBrowserRemoveTags": "Remove tags",
"cardBrowserResetProgress": "Reset progress",
"cardBrowserDelete": "Delete",
"cardBrowserMoreActions": "More actions",
"cardBrowserDone": "Done",
"cardBrowserDeleted": "{count, plural, =1{1 flashcard deleted} other{{count} flashcards deleted}}",
"@cardBrowserDeleted": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserMoveTitle": "{count, plural, =1{Move 1 flashcard to} other{Move {count} flashcards to}}",
"@cardBrowserMoveTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserMoved": "{count, plural, =1{Moved 1 flashcard to “{title}”} other{Moved {count} flashcards to “{title}”}}",
"@cardBrowserMoved": {"placeholders": {"count": {"type": "int"}, "title": {"type": "String"}}},
"cardBrowserAddTagsTitle": "{count, plural, =1{Add tags to 1 flashcard} other{Add tags to {count} flashcards}}",
"@cardBrowserAddTagsTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserAddTo": "{count, plural, =1{Add to 1 flashcard} other{Add to {count} flashcards}}",
"@cardBrowserAddTo": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserRemoveTagsTitle": "{count, plural, =1{Remove tags from 1 flashcard} other{Remove tags from {count} flashcards}}",
"@cardBrowserRemoveTagsTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserRemoveFrom": "{count, plural, =1{Remove from 1 flashcard} other{Remove from {count} flashcards}}",
"@cardBrowserRemoveFrom": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserNoTags": "No tags yet. Create tags from a flashcard's editor, then apply them here.",
"cardBrowserNoTagsOnCards": "The flashcards chosen have no tags.",
"cardBrowserTagsAdded": "{tags, plural, =1{Added 1 tag to {count, plural, =1{1 flashcard} other{{count} flashcards}}} other{Added {tags} tags to {count, plural, =1{1 flashcard} other{{count} flashcards}}}}",
"@cardBrowserTagsAdded": {"placeholders": {"tags": {"type": "int"}, "count": {"type": "int"}}},
"cardBrowserTagsRemoved": "{tags, plural, =1{Removed 1 tag from {count, plural, =1{1 flashcard} other{{count} flashcards}}} other{Removed {tags} tags from {count, plural, =1{1 flashcard} other{{count} flashcards}}}}",
"@cardBrowserTagsRemoved": {"placeholders": {"tags": {"type": "int"}, "count": {"type": "int"}}},
"cardBrowserResetTitle": "{count, plural, =1{Start 1 flashcard afresh?} other{Start {count} flashcards afresh?}}",
"@cardBrowserResetTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserResetMessage": "They count as new again. Their reviews stay in your history and streaks.",
"cardBrowserResetConfirm": "Reset",
"cardBrowserReset": "{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}",
"@cardBrowserReset": {"placeholders": {"count": {"type": "int"}}}
} }

View file

@ -7070,6 +7070,474 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Don\'t suggest again'** /// **'Don\'t suggest again'**
String get pomodoroTopBarDismiss; String get pomodoroTopBarDismiss;
/// No description provided for @cardBrowserTitle.
///
/// In en, this message translates to:
/// **'Flashcards'**
String get cardBrowserTitle;
/// No description provided for @cardBrowserTitleWithName.
///
/// In en, this message translates to:
/// **'{title} — flashcards'**
String cardBrowserTitleWithName(String title);
/// No description provided for @cardBrowserTitleSubtree.
///
/// In en, this message translates to:
/// **'{title} (incl. sub-studysets)'**
String cardBrowserTitleSubtree(String title);
/// No description provided for @cardBrowserBrowse.
///
/// In en, this message translates to:
/// **'Browse'**
String get cardBrowserBrowse;
/// No description provided for @cardBrowserBrowseTooltip.
///
/// In en, this message translates to:
/// **'Browse flashcards'**
String get cardBrowserBrowseTooltip;
/// No description provided for @cardBrowserStudy.
///
/// In en, this message translates to:
/// **'Study'**
String get cardBrowserStudy;
/// No description provided for @cardBrowserEditor.
///
/// In en, this message translates to:
/// **'Editor'**
String get cardBrowserEditor;
/// No description provided for @cardBrowserMore.
///
/// In en, this message translates to:
/// **'More'**
String get cardBrowserMore;
/// No description provided for @cardBrowserImport.
///
/// In en, this message translates to:
/// **'Import flashcards'**
String get cardBrowserImport;
/// No description provided for @cardBrowserGrid.
///
/// In en, this message translates to:
/// **'Grid'**
String get cardBrowserGrid;
/// No description provided for @cardBrowserDetail.
///
/// In en, this message translates to:
/// **'Detail'**
String get cardBrowserDetail;
/// No description provided for @cardBrowserSearch.
///
/// In en, this message translates to:
/// **'Search'**
String get cardBrowserSearch;
/// No description provided for @cardBrowserSort.
///
/// In en, this message translates to:
/// **'Sort'**
String get cardBrowserSort;
/// No description provided for @cardBrowserFilter.
///
/// In en, this message translates to:
/// **'Filter'**
String get cardBrowserFilter;
/// No description provided for @cardBrowserSelect.
///
/// In en, this message translates to:
/// **'Select'**
String get cardBrowserSelect;
/// No description provided for @cardBrowserSortManual.
///
/// In en, this message translates to:
/// **'Manual order'**
String get cardBrowserSortManual;
/// No description provided for @cardBrowserSortNewest.
///
/// In en, this message translates to:
/// **'Newest first'**
String get cardBrowserSortNewest;
/// No description provided for @cardBrowserSortOldest.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get cardBrowserSortOldest;
/// No description provided for @cardBrowserSortAlphabetical.
///
/// In en, this message translates to:
/// **'A–Z (front)'**
String get cardBrowserSortAlphabetical;
/// No description provided for @cardBrowserSortDue.
///
/// In en, this message translates to:
/// **'Due first'**
String get cardBrowserSortDue;
/// No description provided for @cardBrowserSearchHint.
///
/// In en, this message translates to:
/// **'Search flashcards'**
String get cardBrowserSearchHint;
/// No description provided for @cardBrowserCloseSearch.
///
/// In en, this message translates to:
/// **'Close search'**
String get cardBrowserCloseSearch;
/// No description provided for @cardBrowserClearQuery.
///
/// In en, this message translates to:
/// **'Clear query'**
String get cardBrowserClearQuery;
/// No description provided for @cardBrowserFilterTitle.
///
/// In en, this message translates to:
/// **'Filter flashcards'**
String get cardBrowserFilterTitle;
/// No description provided for @cardBrowserFilterType.
///
/// In en, this message translates to:
/// **'Type'**
String get cardBrowserFilterType;
/// No description provided for @cardBrowserTypeAll.
///
/// In en, this message translates to:
/// **'All cards'**
String get cardBrowserTypeAll;
/// No description provided for @cardBrowserTypeFrontBack.
///
/// In en, this message translates to:
/// **'Q&A only'**
String get cardBrowserTypeFrontBack;
/// No description provided for @cardBrowserTypeMultipleChoice.
///
/// In en, this message translates to:
/// **'Multiple-choice only'**
String get cardBrowserTypeMultipleChoice;
/// No description provided for @cardBrowserTypeVocabulary.
///
/// In en, this message translates to:
/// **'Words only'**
String get cardBrowserTypeVocabulary;
/// No description provided for @cardBrowserTypeMatching.
///
/// In en, this message translates to:
/// **'Matching only'**
String get cardBrowserTypeMatching;
/// No description provided for @cardBrowserTypeMap.
///
/// In en, this message translates to:
/// **'Maps only'**
String get cardBrowserTypeMap;
/// No description provided for @cardBrowserFilterTag.
///
/// In en, this message translates to:
/// **'Tag'**
String get cardBrowserFilterTag;
/// No description provided for @cardBrowserAllTags.
///
/// In en, this message translates to:
/// **'All tags'**
String get cardBrowserAllTags;
/// No description provided for @cardBrowserFilterStatus.
///
/// In en, this message translates to:
/// **'Status'**
String get cardBrowserFilterStatus;
/// No description provided for @cardBrowserStatusNew.
///
/// In en, this message translates to:
/// **'New'**
String get cardBrowserStatusNew;
/// No description provided for @cardBrowserStatusLearning.
///
/// In en, this message translates to:
/// **'Learning'**
String get cardBrowserStatusLearning;
/// No description provided for @cardBrowserStatusDue.
///
/// In en, this message translates to:
/// **'Due'**
String get cardBrowserStatusDue;
/// No description provided for @cardBrowserStatusBuried.
///
/// In en, this message translates to:
/// **'Buried'**
String get cardBrowserStatusBuried;
/// No description provided for @cardBrowserStatusReported.
///
/// In en, this message translates to:
/// **'Reported'**
String get cardBrowserStatusReported;
/// No description provided for @cardBrowserClearAll.
///
/// In en, this message translates to:
/// **'Clear all'**
String get cardBrowserClearAll;
/// No description provided for @cardBrowserApply.
///
/// In en, this message translates to:
/// **'Apply'**
String get cardBrowserApply;
/// No description provided for @cardBrowserEmptyTitle.
///
/// In en, this message translates to:
/// **'No flashcards yet'**
String get cardBrowserEmptyTitle;
/// No description provided for @cardBrowserEmptyHint.
///
/// In en, this message translates to:
/// **'Create some to browse them here.'**
String get cardBrowserEmptyHint;
/// No description provided for @cardBrowserCreate.
///
/// In en, this message translates to:
/// **'Create flashcards'**
String get cardBrowserCreate;
/// No description provided for @cardBrowserNoMatches.
///
/// In en, this message translates to:
/// **'No flashcards match'**
String get cardBrowserNoMatches;
/// No description provided for @cardBrowserNoMatchesHint.
///
/// In en, this message translates to:
/// **'Try a different search or filter.'**
String get cardBrowserNoMatchesHint;
/// No description provided for @cardBrowserClearFilters.
///
/// In en, this message translates to:
/// **'Clear search & filters'**
String get cardBrowserClearFilters;
/// No description provided for @cardBrowserRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get cardBrowserRetry;
/// No description provided for @cardBrowserPrevious.
///
/// In en, this message translates to:
/// **'Previous'**
String get cardBrowserPrevious;
/// No description provided for @cardBrowserNext.
///
/// In en, this message translates to:
/// **'Next'**
String get cardBrowserNext;
/// No description provided for @cardBrowserBuried.
///
/// In en, this message translates to:
/// **'Buried'**
String get cardBrowserBuried;
/// No description provided for @cardBrowserSelectAll.
///
/// In en, this message translates to:
/// **'Select all'**
String get cardBrowserSelectAll;
/// No description provided for @cardBrowserDeselectAll.
///
/// In en, this message translates to:
/// **'Deselect all'**
String get cardBrowserDeselectAll;
/// No description provided for @cardBrowserSelected.
///
/// In en, this message translates to:
/// **'{count} selected'**
String cardBrowserSelected(int count);
/// No description provided for @cardBrowserRangeHintTouch.
///
/// In en, this message translates to:
/// **'Long-press another card to select everything in between.'**
String get cardBrowserRangeHintTouch;
/// No description provided for @cardBrowserRangeHintPointer.
///
/// In en, this message translates to:
/// **'Shift-click another card to select everything in between.'**
String get cardBrowserRangeHintPointer;
/// No description provided for @cardBrowserMove.
///
/// In en, this message translates to:
/// **'Move'**
String get cardBrowserMove;
/// No description provided for @cardBrowserAddTags.
///
/// In en, this message translates to:
/// **'Add tags'**
String get cardBrowserAddTags;
/// No description provided for @cardBrowserRemoveTags.
///
/// In en, this message translates to:
/// **'Remove tags'**
String get cardBrowserRemoveTags;
/// No description provided for @cardBrowserResetProgress.
///
/// In en, this message translates to:
/// **'Reset progress'**
String get cardBrowserResetProgress;
/// No description provided for @cardBrowserDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get cardBrowserDelete;
/// No description provided for @cardBrowserMoreActions.
///
/// In en, this message translates to:
/// **'More actions'**
String get cardBrowserMoreActions;
/// No description provided for @cardBrowserDone.
///
/// In en, this message translates to:
/// **'Done'**
String get cardBrowserDone;
/// No description provided for @cardBrowserDeleted.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard deleted} other{{count} flashcards deleted}}'**
String cardBrowserDeleted(int count);
/// No description provided for @cardBrowserMoveTitle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Move 1 flashcard to} other{Move {count} flashcards to}}'**
String cardBrowserMoveTitle(int count);
/// No description provided for @cardBrowserMoved.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Moved 1 flashcard to “{title}”} other{Moved {count} flashcards to “{title}”}}'**
String cardBrowserMoved(int count, String title);
/// No description provided for @cardBrowserAddTagsTitle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Add tags to 1 flashcard} other{Add tags to {count} flashcards}}'**
String cardBrowserAddTagsTitle(int count);
/// No description provided for @cardBrowserAddTo.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Add to 1 flashcard} other{Add to {count} flashcards}}'**
String cardBrowserAddTo(int count);
/// No description provided for @cardBrowserRemoveTagsTitle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Remove tags from 1 flashcard} other{Remove tags from {count} flashcards}}'**
String cardBrowserRemoveTagsTitle(int count);
/// No description provided for @cardBrowserRemoveFrom.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Remove from 1 flashcard} other{Remove from {count} flashcards}}'**
String cardBrowserRemoveFrom(int count);
/// No description provided for @cardBrowserNoTags.
///
/// In en, this message translates to:
/// **'No tags yet. Create tags from a flashcard\'s editor, then apply them here.'**
String get cardBrowserNoTags;
/// No description provided for @cardBrowserNoTagsOnCards.
///
/// In en, this message translates to:
/// **'The flashcards chosen have no tags.'**
String get cardBrowserNoTagsOnCards;
/// No description provided for @cardBrowserTagsAdded.
///
/// In en, this message translates to:
/// **'{tags, plural, =1{Added 1 tag to {count, plural, =1{1 flashcard} other{{count} flashcards}}} other{Added {tags} tags to {count, plural, =1{1 flashcard} other{{count} flashcards}}}}'**
String cardBrowserTagsAdded(int tags, int count);
/// No description provided for @cardBrowserTagsRemoved.
///
/// In en, this message translates to:
/// **'{tags, plural, =1{Removed 1 tag from {count, plural, =1{1 flashcard} other{{count} flashcards}}} other{Removed {tags} tags from {count, plural, =1{1 flashcard} other{{count} flashcards}}}}'**
String cardBrowserTagsRemoved(int tags, int count);
/// No description provided for @cardBrowserResetTitle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Start 1 flashcard afresh?} other{Start {count} flashcards afresh?}}'**
String cardBrowserResetTitle(int count);
/// No description provided for @cardBrowserResetMessage.
///
/// In en, this message translates to:
/// **'They count as new again. Their reviews stay in your history and streaks.'**
String get cardBrowserResetMessage;
/// No description provided for @cardBrowserResetConfirm.
///
/// In en, this message translates to:
/// **'Reset'**
String get cardBrowserResetConfirm;
/// No description provided for @cardBrowserReset.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}'**
String cardBrowserReset(int count);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -4332,4 +4332,363 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pomodoroTopBarDismiss => 'Nicht mehr vorschlagen'; String get pomodoroTopBarDismiss => 'Nicht mehr vorschlagen';
@override
String get cardBrowserTitle => 'Karteikarten';
@override
String cardBrowserTitleWithName(String title) {
return '$title – Karteikarten';
}
@override
String cardBrowserTitleSubtree(String title) {
return '$title (mit Unter-Lernsets)';
}
@override
String get cardBrowserBrowse => 'Durchsuchen';
@override
String get cardBrowserBrowseTooltip => 'Karteikarten durchsuchen';
@override
String get cardBrowserStudy => 'Lernen';
@override
String get cardBrowserEditor => 'Editor';
@override
String get cardBrowserMore => 'Mehr';
@override
String get cardBrowserImport => 'Karteikarten importieren';
@override
String get cardBrowserGrid => 'Raster';
@override
String get cardBrowserDetail => 'Detail';
@override
String get cardBrowserSearch => 'Suchen';
@override
String get cardBrowserSort => 'Sortieren';
@override
String get cardBrowserFilter => 'Filtern';
@override
String get cardBrowserSelect => 'Auswählen';
@override
String get cardBrowserSortManual => 'Eigene Reihenfolge';
@override
String get cardBrowserSortNewest => 'Neueste zuerst';
@override
String get cardBrowserSortOldest => 'Älteste zuerst';
@override
String get cardBrowserSortAlphabetical => 'A–Z (Vorderseite)';
@override
String get cardBrowserSortDue => 'Fällige zuerst';
@override
String get cardBrowserSearchHint => 'Karteikarten durchsuchen';
@override
String get cardBrowserCloseSearch => 'Suche schließen';
@override
String get cardBrowserClearQuery => 'Suche leeren';
@override
String get cardBrowserFilterTitle => 'Karteikarten filtern';
@override
String get cardBrowserFilterType => 'Art';
@override
String get cardBrowserTypeAll => 'Alle Karten';
@override
String get cardBrowserTypeFrontBack => 'Nur Frage und Antwort';
@override
String get cardBrowserTypeMultipleChoice => 'Nur Multiple Choice';
@override
String get cardBrowserTypeVocabulary => 'Nur Wörter';
@override
String get cardBrowserTypeMatching => 'Nur Zuordnungen';
@override
String get cardBrowserTypeMap => 'Nur Karten';
@override
String get cardBrowserFilterTag => 'Tag';
@override
String get cardBrowserAllTags => 'Alle Tags';
@override
String get cardBrowserFilterStatus => 'Stand';
@override
String get cardBrowserStatusNew => 'Neu';
@override
String get cardBrowserStatusLearning => 'Im Lernen';
@override
String get cardBrowserStatusDue => 'Fällig';
@override
String get cardBrowserStatusBuried => 'Zurückgestellt';
@override
String get cardBrowserStatusReported => 'Gemeldet';
@override
String get cardBrowserClearAll => 'Alle zurücksetzen';
@override
String get cardBrowserApply => 'Anwenden';
@override
String get cardBrowserEmptyTitle => 'Noch keine Karteikarten';
@override
String get cardBrowserEmptyHint =>
'Erstelle welche, um sie hier zu durchsuchen.';
@override
String get cardBrowserCreate => 'Karteikarten erstellen';
@override
String get cardBrowserNoMatches => 'Keine Karteikarte passt';
@override
String get cardBrowserNoMatchesHint =>
'Versuche eine andere Suche oder einen anderen Filter.';
@override
String get cardBrowserClearFilters => 'Suche und Filter zurücksetzen';
@override
String get cardBrowserRetry => 'Erneut versuchen';
@override
String get cardBrowserPrevious => 'Vorige';
@override
String get cardBrowserNext => 'Nächste';
@override
String get cardBrowserBuried => 'Zurückgestellt';
@override
String get cardBrowserSelectAll => 'Alle auswählen';
@override
String get cardBrowserDeselectAll => 'Auswahl aufheben';
@override
String cardBrowserSelected(int count) {
return '$count ausgewählt';
}
@override
String get cardBrowserRangeHintTouch =>
'Halte eine andere Karte gedrückt, um alles dazwischen auszuwählen.';
@override
String get cardBrowserRangeHintPointer =>
'Klicke mit gedrückter Umschalttaste auf eine andere Karte, um alles dazwischen auszuwählen.';
@override
String get cardBrowserMove => 'Verschieben';
@override
String get cardBrowserAddTags => 'Tags hinzufügen';
@override
String get cardBrowserRemoveTags => 'Tags entfernen';
@override
String get cardBrowserResetProgress => 'Fortschritt zurücksetzen';
@override
String get cardBrowserDelete => 'Löschen';
@override
String get cardBrowserMoreActions => 'Weitere Aktionen';
@override
String get cardBrowserDone => 'Fertig';
@override
String cardBrowserDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten gelöscht',
one: '1 Karteikarte gelöscht',
);
return '$_temp0';
}
@override
String cardBrowserMoveTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten verschieben nach',
one: '1 Karteikarte verschieben nach',
);
return '$_temp0';
}
@override
String cardBrowserMoved(int count, String title) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten nach „$title“ verschoben',
one: '1 Karteikarte nach „$title“ verschoben',
);
return '$_temp0';
}
@override
String cardBrowserAddTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Tags zu $count Karteikarten hinzufügen',
one: 'Tags zu 1 Karteikarte hinzufügen',
);
return '$_temp0';
}
@override
String cardBrowserAddTo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Zu $count Karteikarten hinzufügen',
one: 'Zu 1 Karteikarte hinzufügen',
);
return '$_temp0';
}
@override
String cardBrowserRemoveTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Tags von $count Karteikarten entfernen',
one: 'Tags von 1 Karteikarte entfernen',
);
return '$_temp0';
}
@override
String cardBrowserRemoveFrom(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Von $count Karteikarten entfernen',
one: 'Von 1 Karteikarte entfernen',
);
return '$_temp0';
}
@override
String get cardBrowserNoTags =>
'Noch keine Tags. Erstelle Tags im Editor einer Karteikarte und weise sie dann hier zu.';
@override
String get cardBrowserNoTagsOnCards =>
'Die ausgewählten Karteikarten haben keine Tags.';
@override
String cardBrowserTagsAdded(int tags, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
String _temp2 = intl.Intl.pluralLogic(
tags,
locale: localeName,
other: '$tags Tags zu $_temp0 hinzugefügt',
one: '1 Tag zu $_temp1 hinzugefügt',
);
return '$_temp2';
}
@override
String cardBrowserTagsRemoved(int tags, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
String _temp2 = intl.Intl.pluralLogic(
tags,
locale: localeName,
other: '$tags Tags von $_temp0 entfernt',
one: '1 Tag von $_temp1 entfernt',
);
return '$_temp2';
}
@override
String cardBrowserResetTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten neu beginnen?',
one: '1 Karteikarte neu beginnen?',
);
return '$_temp0';
}
@override
String get cardBrowserResetMessage =>
'Sie gelten wieder als neu. Ihre Wiederholungen bleiben in deinem Verlauf und deinen Serien.';
@override
String get cardBrowserResetConfirm => 'Zurücksetzen';
@override
String cardBrowserReset(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten beginnen neu',
one: '1 Karteikarte beginnt neu',
);
return '$_temp0';
}
} }

View file

@ -4299,6 +4299,362 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pomodoroTopBarDismiss => 'Don\'t suggest again'; String get pomodoroTopBarDismiss => 'Don\'t suggest again';
@override
String get cardBrowserTitle => 'Flashcards';
@override
String cardBrowserTitleWithName(String title) {
return '$title — flashcards';
}
@override
String cardBrowserTitleSubtree(String title) {
return '$title (incl. sub-studysets)';
}
@override
String get cardBrowserBrowse => 'Browse';
@override
String get cardBrowserBrowseTooltip => 'Browse flashcards';
@override
String get cardBrowserStudy => 'Study';
@override
String get cardBrowserEditor => 'Editor';
@override
String get cardBrowserMore => 'More';
@override
String get cardBrowserImport => 'Import flashcards';
@override
String get cardBrowserGrid => 'Grid';
@override
String get cardBrowserDetail => 'Detail';
@override
String get cardBrowserSearch => 'Search';
@override
String get cardBrowserSort => 'Sort';
@override
String get cardBrowserFilter => 'Filter';
@override
String get cardBrowserSelect => 'Select';
@override
String get cardBrowserSortManual => 'Manual order';
@override
String get cardBrowserSortNewest => 'Newest first';
@override
String get cardBrowserSortOldest => 'Oldest first';
@override
String get cardBrowserSortAlphabetical => 'A–Z (front)';
@override
String get cardBrowserSortDue => 'Due first';
@override
String get cardBrowserSearchHint => 'Search flashcards';
@override
String get cardBrowserCloseSearch => 'Close search';
@override
String get cardBrowserClearQuery => 'Clear query';
@override
String get cardBrowserFilterTitle => 'Filter flashcards';
@override
String get cardBrowserFilterType => 'Type';
@override
String get cardBrowserTypeAll => 'All cards';
@override
String get cardBrowserTypeFrontBack => 'Q&A only';
@override
String get cardBrowserTypeMultipleChoice => 'Multiple-choice only';
@override
String get cardBrowserTypeVocabulary => 'Words only';
@override
String get cardBrowserTypeMatching => 'Matching only';
@override
String get cardBrowserTypeMap => 'Maps only';
@override
String get cardBrowserFilterTag => 'Tag';
@override
String get cardBrowserAllTags => 'All tags';
@override
String get cardBrowserFilterStatus => 'Status';
@override
String get cardBrowserStatusNew => 'New';
@override
String get cardBrowserStatusLearning => 'Learning';
@override
String get cardBrowserStatusDue => 'Due';
@override
String get cardBrowserStatusBuried => 'Buried';
@override
String get cardBrowserStatusReported => 'Reported';
@override
String get cardBrowserClearAll => 'Clear all';
@override
String get cardBrowserApply => 'Apply';
@override
String get cardBrowserEmptyTitle => 'No flashcards yet';
@override
String get cardBrowserEmptyHint => 'Create some to browse them here.';
@override
String get cardBrowserCreate => 'Create flashcards';
@override
String get cardBrowserNoMatches => 'No flashcards match';
@override
String get cardBrowserNoMatchesHint => 'Try a different search or filter.';
@override
String get cardBrowserClearFilters => 'Clear search & filters';
@override
String get cardBrowserRetry => 'Retry';
@override
String get cardBrowserPrevious => 'Previous';
@override
String get cardBrowserNext => 'Next';
@override
String get cardBrowserBuried => 'Buried';
@override
String get cardBrowserSelectAll => 'Select all';
@override
String get cardBrowserDeselectAll => 'Deselect all';
@override
String cardBrowserSelected(int count) {
return '$count selected';
}
@override
String get cardBrowserRangeHintTouch =>
'Long-press another card to select everything in between.';
@override
String get cardBrowserRangeHintPointer =>
'Shift-click another card to select everything in between.';
@override
String get cardBrowserMove => 'Move';
@override
String get cardBrowserAddTags => 'Add tags';
@override
String get cardBrowserRemoveTags => 'Remove tags';
@override
String get cardBrowserResetProgress => 'Reset progress';
@override
String get cardBrowserDelete => 'Delete';
@override
String get cardBrowserMoreActions => 'More actions';
@override
String get cardBrowserDone => 'Done';
@override
String cardBrowserDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards deleted',
one: '1 flashcard deleted',
);
return '$_temp0';
}
@override
String cardBrowserMoveTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Move $count flashcards to',
one: 'Move 1 flashcard to',
);
return '$_temp0';
}
@override
String cardBrowserMoved(int count, String title) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Moved $count flashcards to “$title”',
one: 'Moved 1 flashcard to “$title”',
);
return '$_temp0';
}
@override
String cardBrowserAddTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add tags to $count flashcards',
one: 'Add tags to 1 flashcard',
);
return '$_temp0';
}
@override
String cardBrowserAddTo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add to $count flashcards',
one: 'Add to 1 flashcard',
);
return '$_temp0';
}
@override
String cardBrowserRemoveTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Remove tags from $count flashcards',
one: 'Remove tags from 1 flashcard',
);
return '$_temp0';
}
@override
String cardBrowserRemoveFrom(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Remove from $count flashcards',
one: 'Remove from 1 flashcard',
);
return '$_temp0';
}
@override
String get cardBrowserNoTags =>
'No tags yet. Create tags from a flashcard\'s editor, then apply them here.';
@override
String get cardBrowserNoTagsOnCards => 'The flashcards chosen have no tags.';
@override
String cardBrowserTagsAdded(int tags, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
String _temp2 = intl.Intl.pluralLogic(
tags,
locale: localeName,
other: 'Added $tags tags to $_temp0',
one: 'Added 1 tag to $_temp1',
);
return '$_temp2';
}
@override
String cardBrowserTagsRemoved(int tags, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
String _temp2 = intl.Intl.pluralLogic(
tags,
locale: localeName,
other: 'Removed $tags tags from $_temp0',
one: 'Removed 1 tag from $_temp1',
);
return '$_temp2';
}
@override
String cardBrowserResetTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Start $count flashcards afresh?',
one: 'Start 1 flashcard afresh?',
);
return '$_temp0';
}
@override
String get cardBrowserResetMessage =>
'They count as new again. Their reviews stay in your history and streaks.';
@override
String get cardBrowserResetConfirm => 'Reset';
@override
String cardBrowserReset(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards start afresh',
one: '1 flashcard starts afresh',
);
return '$_temp0';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart'; import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
@ -180,6 +181,20 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
); );
}, },
), ),
// Browsing a studyset's cards: searching, filtering and changing many at
// once.
GoRoute(
path: '/studysets/:studysetId/flashcards',
builder: (context, state) => _inStudyset(
context,
state.pathParameters['studysetId']!,
(studyset, all) => CardBrowserScreen(
studyset: studyset,
all: all,
subtree: state.uri.queryParameters['subtree'] == '1',
),
),
),
// The composer: writing a studyset's cards, opened on a new card or on // The composer: writing a studyset's cards, opened on a new card or on
// one of them. Before the card route: its `new` would otherwise read as // one of them. Before the card route: its `new` would otherwise read as
// a card's id. // a card's id.

View file

@ -27,6 +27,12 @@ class DeviceSettingsBloc(
(event, emit) => (event, emit) =>
_apply(emit, state.withPomodoroWhileStudying(event.shown)), _apply(emit, state.withPomodoroWhileStudying(event.shown)),
); );
on<DeviceSettingsEventBrowserViewChosen>(
(event, emit) => _apply(
emit,
state.withBrowserView(event.studysetId, detailed: event.detailed),
),
);
} }
Future<void> _apply( Future<void> _apply(
@ -77,3 +83,13 @@ final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
@override @override
List<Object?> get props => [shown]; List<Object?> get props => [shown];
} }
/// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(
final String studysetId, {
required final bool detailed,
}) extends DeviceSettingsEvent {
@override
List<Object?> get props => [studysetId, detailed];
}

View file

@ -47,6 +47,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
CitationStyle.values.asNameMap()[json['citationStyle']] ?? CitationStyle.values.asNameMap()[json['citationStyle']] ??
CitationStyle.apa, CitationStyle.apa,
pomodoroWhileStudying: json['pomodoroWhileStudying'] != false, pomodoroWhileStudying: json['pomodoroWhileStudying'] != false,
detailedBrowsers: {
if (json['detailedBrowsers'] case final List<Object?> ids)
for (final id in ids.whereType<String>()) id,
},
); );
} on FormatException catch (error) { } on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error); log('The device settings are unreadable', name: 'Settings', error: error);
@ -64,6 +68,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'language': ?settings.language, 'language': ?settings.language,
'citationStyle': settings.citationStyle.name, 'citationStyle': settings.citationStyle.name,
'pomodoroWhileStudying': settings.pomodoroWhileStudying, 'pomodoroWhileStudying': settings.pomodoroWhileStudying,
'detailedBrowsers': [...settings.detailedBrowsers],
}), }),
flush: true, flush: true,
); );

View file

@ -9,8 +9,9 @@ enum Appearance() {
} }
/// The settings of one device, which stay on it (decided 2026-10-07): how the /// The settings of one device, which stay on it (decided 2026-10-07): how the
/// app looks, the language it speaks, the style it cites sources in, and /// app looks, the language it speaks, the style it cites sources in,
/// whether studying shows the Pomodoro timer. /// whether studying shows the Pomodoro timer, and which studysets' card
/// browsers show one card at a time.
/// Unlike the person's settings, they don't follow anyone to other devices, /// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in. /// and they apply before anyone is signed in.
final class const DeviceSettings({ final class const DeviceSettings({
@ -25,6 +26,10 @@ final class const DeviceSettings({
/// Whether a study session shows the Pomodoro timer's countdown. /// Whether a study session shows the Pomodoro timer's countdown.
final bool pomodoroWhileStudying = true, final bool pomodoroWhileStudying = true,
/// The studysets whose card browser shows one card at a time beside the
/// list (detail) rather than a grid, as last left.
final Set<String> detailedBrowsers = const {},
}) with Equatable { }) with Equatable {
/// These settings with [appearance] instead. /// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings( DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
@ -32,6 +37,7 @@ final class const DeviceSettings({
language: language, language: language,
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
); );
/// These settings with [language] instead; null for the device's. /// These settings with [language] instead; null for the device's.
@ -40,6 +46,7 @@ final class const DeviceSettings({
language: language, language: language,
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
); );
/// These settings with [citationStyle] instead. /// These settings with [citationStyle] instead.
@ -49,6 +56,7 @@ final class const DeviceSettings({
language: language, language: language,
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
); );
/// These settings with [shown] for the Pomodoro timer while studying. /// These settings with [shown] for the Pomodoro timer while studying.
@ -57,6 +65,20 @@ final class const DeviceSettings({
language: language, language: language,
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: shown, pomodoroWhileStudying: shown,
detailedBrowsers: detailedBrowsers,
);
/// These settings with the card browser of [studysetId] showing one card
/// at a time ([detailed]) or a grid.
DeviceSettings withBrowserView(String studysetId, {required bool detailed}) =>
DeviceSettings(
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailed
? {...detailedBrowsers, studysetId}
: ({...detailedBrowsers}..remove(studysetId)),
); );
@override @override
@ -65,5 +87,6 @@ final class const DeviceSettings({
language, language,
citationStyle, citationStyle,
pomodoroWhileStudying, pomodoroWhileStudying,
detailedBrowsers,
]; ];
} }

View file

@ -33,6 +33,13 @@ abstract final class RoutePaths() {
queryParameters: query.isEmpty ? null : query, queryParameters: query.isEmpty ? null : query,
).toString(); ).toString();
/// A studyset's card browser, over the tabs; with [subtree], also the
/// cards of every studyset inside it.
static String cardBrowser(String studysetId, {bool subtree = false}) => Uri(
path: '/studysets/$studysetId/flashcards',
queryParameters: subtree ? const {'subtree': '1'} : null,
).toString();
/// A new card in a studyset, over the tabs. /// A new card in a studyset, over the tabs.
static String newCard(String studysetId) => static String newCard(String studysetId) =>
'/studysets/$studysetId/cards/new'; '/studysets/$studysetId/cards/new';

View file

@ -0,0 +1,448 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// The card browser: the cards of the studysets [_studysetIds] (one, or a
/// studyset and everything inside it, in tree order) with their tags and
/// schedules, those a search, filters and a sort pick; a selection of them,
/// and what can be done to it all at once (move, tag, untag, start afresh;
/// deleting goes through [CardDeletions], whose Undo the screen offers).
/// Follows every change, local or synced.
class CardBrowserBloc(
final CardRepository _cards,
final TagRepository _tags,
final ReviewRepository _reviews,
final CardDeletions _deletions,
final List<String> _studysetIds,
) extends Bloc<CardBrowserEvent, CardBrowserState> {
this : super(const CardBrowserState()) {
on<CardBrowserEventStarted>(_onStarted);
on<CardBrowserEventRefreshed>(_onRefreshed);
on<CardBrowserEventQueryChanged>(
(event, emit) => emit(
_shown(
state.copyWith(filter: _withQuery(state.filter, event.query)),
),
),
);
on<CardBrowserEventFilterChanged>(
(event, emit) => emit(
_shown(
state.copyWith(
filter: (
query: state.filter.query,
kind: event.kind,
tagId: event.tagId,
states: event.states,
),
),
),
),
);
on<CardBrowserEventFiltersCleared>(
(event, emit) => emit(_shown(state.copyWith(filter: noCardFilter))),
);
on<CardBrowserEventSortChanged>(
(event, emit) => emit(_shown(state.copyWith(sort: event.sort))),
);
on<CardBrowserEventSelectingToggled>(
(event, emit) => emit(
state.copyWith(
selecting: !state.selecting,
selected: const {},
anchor: null,
),
),
);
on<CardBrowserEventCardSelected>(_onCardSelected);
on<CardBrowserEventAllSelected>(
(event, emit) => emit(
state.copyWith(
selected: {for (final each in state.shown) each.card.id},
),
),
);
on<CardBrowserEventAllDeselected>(
(event, emit) => emit(state.copyWith(selected: const {}, anchor: null)),
);
on<CardBrowserEventMoved>((event, emit) async {
final ids = _selectedInOrder();
emit(state.copyWith(selected: const {}, anchor: null));
await _cards.moveTo(event.studysetId, ids);
});
on<CardBrowserEventTagsAdded>((event, emit) async {
await _tags.addTags({
for (final id in state.selected) id: event.tagIds,
});
});
on<CardBrowserEventTagsRemoved>((event, emit) async {
for (final each in state.cards) {
if (state.selected.contains(each.card.id)) {
final ids = {for (final tag in each.tags) tag.id};
if (ids.intersection(event.tagIds).isNotEmpty) {
await _tags.setTagsOf(each.card.id, ids.difference(event.tagIds));
}
}
}
});
on<CardBrowserEventProgressReset>(
(event, emit) => _reviews.reset(state.selected, clock.now()),
);
}
final _cardsByStudyset = <String, List<Card>>{};
var _tagging = Tagging.empty;
var _schedules = <String, CardSchedule>{};
var _pending = <String>{};
/// What a search looks through in each card, kept while the card and
/// its tags are the same: thousands of cards, a search at every letter.
final _searchTexts = <String, ({Card card, List<Tag> tags, String text})>{};
/// Whether the schedules are being worked out, and whether to again.
var _working = false;
var _workAgain = false;
Future<void> _onStarted(
CardBrowserEventStarted event,
Emitter<CardBrowserState> emit,
) async {
void refresh() => add(const CardBrowserEventRefreshed());
await Future.wait([
for (final id in _studysetIds)
emit.onEach(
_cards.watch(id),
onData: (cards) {
_cardsByStudyset[id] = cards;
refresh();
},
onError: (error, _) {
log('Reading cards failed', name: 'Browser', error: error);
if (!state.loaded) {
emit(state.copyWith(failed: true));
}
},
),
emit.onEach(
_tags.watch(),
onData: (tagging) {
_tagging = tagging;
refresh();
},
// Without tags the cards still show.
onError: (_, _) {},
),
emit.onEach(_reviews.changes, onData: (_) => refresh()),
emit.onEach(
_deletions.watchPending(),
onData: (pending) {
_pending = pending;
refresh();
},
),
]);
}
/// Works the cards' schedules out and lists them anew, one run at a time
/// (a run asked for meanwhile follows once).
Future<void> _onRefreshed(
CardBrowserEventRefreshed event,
Emitter<CardBrowserState> emit,
) async {
if (_working) {
_workAgain = true;
return;
}
_working = true;
try {
do {
_workAgain = false;
if (_cardsByStudyset.length < _studysetIds.length) {
// Not every studyset is read yet.
continue;
}
try {
_schedules = await _reviews.schedulesOf([
for (final cards in _cardsByStudyset.values)
for (final card in cards) card.id,
]);
} on Object catch (error) {
// Without schedules the cards still show; due sorts last.
log('Working out schedules failed', name: 'Browser', error: error);
}
final cards = [
for (final id in _studysetIds)
for (final card in _cardsByStudyset[id] ?? const <Card>[])
if (!_pending.contains(card.id))
(
card: card,
studysetId: id,
tags: _tagging.of(card.id),
schedule: _schedules[card.id],
),
];
final here = {for (final each in cards) each.card.id};
emit(
_shown(
state.copyWith(
loaded: true,
failed: false,
cards: cards,
// A card gone (deleted, moved elsewhere) is no longer chosen.
selected: state.selected.intersection(here),
),
),
);
} while (_workAgain && !isClosed);
} finally {
_working = false;
}
}
void _onCardSelected(
CardBrowserEventCardSelected event,
Emitter<CardBrowserState> emit,
) {
final id = event.cardId;
final anchor = state.anchor;
final order = [for (final each in state.shown) each.card.id];
if (event.range && anchor != null && anchor != id) {
// Everything from the card chosen before to this one.
final (from, to) = (order.indexOf(anchor), order.indexOf(id));
if (from >= 0 && to >= 0) {
final (start, end) = from < to ? (from, to) : (to, from);
emit(
state.copyWith(
selected: {...state.selected, ...order.sublist(start, end + 1)},
),
);
return;
}
}
final selected = {...state.selected};
final chosen = selected.add(id) || !selected.remove(id);
emit(
state.copyWith(
selected: selected,
anchor: chosen ? id : null,
),
);
}
/// The chosen cards, in the order shown.
List<String> _selectedInOrder() => [
for (final each in state.shown)
if (state.selected.contains(each.card.id)) each.card.id,
];
/// [state] with the cards its filter picks, in its order.
CardBrowserState _shown(CardBrowserState state) {
final filter = state.filter;
final now = clock.now();
final startOfToday = startOfLocalDay(now);
final picked = [
for (final each in state.cards)
if ((filter.kind == null ||
CardBrowserKind.of(each.card.content) == filter.kind) &&
(filter.tagId == null ||
each.tags.any((tag) => tag.id == filter.tagId)) &&
(filter.states.isEmpty ||
filter.states.any(
(status) => isInState(
each,
status,
now: now,
startOfToday: startOfToday,
),
)) &&
matchesQuery(_searchTextOf(each), filter.query))
each,
];
return state.copyWith(shown: sortedCards(picked, state.sort));
}
String _searchTextOf(BrowsedCard each) {
final known = _searchTexts[each.card.id];
if (known != null &&
identical(known.card, each.card) &&
identical(known.tags, each.tags)) {
return known.text;
}
final text = searchTextOf(each.card, each.tags);
_searchTexts[each.card.id] = (card: each.card, tags: each.tags, text: text);
return text;
}
}
CardBrowserFilter _withQuery(CardBrowserFilter filter, String query) => (
query: query,
kind: filter.kind,
tagId: filter.tagId,
states: filter.states,
);
const _keep = Object();
/// The browser: every card read and those shown, how they are picked and
/// ordered, and which are chosen.
final class const CardBrowserState({
/// Whether the cards were read.
final bool loaded = false,
/// Whether reading them failed before they could show.
final bool failed = false,
/// Every card of the studysets, in their own order.
final List<BrowsedCard> cards = const [],
/// The cards the filter picks, in the sort's order.
final List<BrowsedCard> shown = const [],
final CardBrowserFilter filter = noCardFilter,
final CardBrowserSort sort = CardBrowserSort.manual,
/// Whether cards are being chosen, and which are.
final bool selecting = false,
final Set<String> selected = const {},
/// The card chosen last on its own, from which a range is chosen.
final String? anchor,
}) with Equatable {
/// Whether a search or a filter narrows the cards.
bool get isFiltered =>
filter.query.trim().isNotEmpty ||
filter.kind != null ||
filter.tagId != null ||
filter.states.isNotEmpty;
/// This state with some fields replaced; [anchor] can be cleared by
/// passing null on purpose.
CardBrowserState copyWith({
bool? loaded,
bool? failed,
List<BrowsedCard>? cards,
List<BrowsedCard>? shown,
CardBrowserFilter? filter,
CardBrowserSort? sort,
bool? selecting,
Set<String>? selected,
Object? anchor = _keep,
}) => CardBrowserState(
loaded: loaded ?? this.loaded,
failed: failed ?? this.failed,
cards: cards ?? this.cards,
shown: shown ?? this.shown,
filter: filter ?? this.filter,
sort: sort ?? this.sort,
selecting: selecting ?? this.selecting,
selected: selected ?? this.selected,
anchor: identical(anchor, _keep) ? this.anchor : anchor as String?,
);
@override
List<Object?> get props => [
loaded,
failed,
cards,
shown,
filter,
sort,
selecting,
selected,
anchor,
];
}
/// What happens in the browser.
sealed class const CardBrowserEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The browser opens: follow the cards, tags, reviews and deletions.
final class const CardBrowserEventStarted() extends CardBrowserEvent;
/// Something the cards depend on changed.
final class const CardBrowserEventRefreshed() extends CardBrowserEvent;
/// The person searches for [query].
final class const CardBrowserEventQueryChanged(final String query)
extends CardBrowserEvent {
@override
List<Object?> get props => [query];
}
/// The person filters by [kind], [tagId] and [states] (none: any).
final class const CardBrowserEventFilterChanged({
final CardBrowserKind? kind,
final String? tagId,
final Set<CardStatus> states = const {},
}) extends CardBrowserEvent {
@override
List<Object?> get props => [kind, tagId, states];
}
/// The person clears the search and every filter.
final class const CardBrowserEventFiltersCleared() extends CardBrowserEvent;
/// The person orders the cards by [sort].
final class const CardBrowserEventSortChanged(final CardBrowserSort sort)
extends CardBrowserEvent {
@override
List<Object?> get props => [sort];
}
/// The person starts or stops choosing cards.
final class const CardBrowserEventSelectingToggled() extends CardBrowserEvent;
/// The person chooses the card [cardId], or lets it go; with [range],
/// every card from the one chosen before to it.
final class const CardBrowserEventCardSelected(
final String cardId, {
final bool range = false,
}) extends CardBrowserEvent {
@override
List<Object?> get props => [cardId, range];
}
/// The person chooses every card shown.
final class const CardBrowserEventAllSelected() extends CardBrowserEvent;
/// The person lets every card go.
final class const CardBrowserEventAllDeselected() extends CardBrowserEvent;
/// The person moves the chosen cards into [studysetId].
final class const CardBrowserEventMoved(final String studysetId)
extends CardBrowserEvent {
@override
List<Object?> get props => [studysetId];
}
/// The person gives the chosen cards the tags [tagIds].
final class const CardBrowserEventTagsAdded(final Set<String> tagIds)
extends CardBrowserEvent {
@override
List<Object?> get props => [tagIds];
}
/// The person takes the tags [tagIds] from the chosen cards.
final class const CardBrowserEventTagsRemoved(final Set<String> tagIds)
extends CardBrowserEvent {
@override
List<Object?> get props => [tagIds];
}
/// The person starts the chosen cards afresh, as new.
final class const CardBrowserEventProgressReset() extends CardBrowserEvent;

View file

@ -56,6 +56,13 @@ abstract interface class CardGateway() {
/// only column that changes. /// only column that changes.
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt); Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
/// Puts the card [cardId] into the studyset [studysetId], at [position].
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
);
/// Blanks the payload of every revision of the card [cardId]. /// Blanks the payload of every revision of the card [cardId].
Future<void> blankRevisions(String cardId); Future<void> blankRevisions(String cardId);

View file

@ -243,6 +243,17 @@ class CardRepository(final CardGateway _gateway) {
_changes.add(null); _changes.add(null);
} }
/// Moves the cards [cardIds] into [studysetId], at its end in their
/// order (as the browser lists them).
Future<void> moveTo(String studysetId, List<String> cardIds) async {
var after = await _lastPosition(studysetId);
for (final id in cardIds) {
after = FractionalIndexing.generateKeyBetween(after, null);
await _gateway.updateStudyset(id, studysetId, after);
}
_changes.add(null);
}
/// Deletes [card]. Its revisions are blanked first: the sync engine keeps /// Deletes [card]. Its revisions are blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2). /// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(Card card) => deleteById(card.id); Future<void> delete(Card card) => deleteById(card.id);

View file

@ -160,6 +160,31 @@ class SyncCardGateway(
_sync.requestSync(); _sync.requestSync();
} }
@override
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
) async {
final card = await protocol.Card.db.findById(
_db,
protocol.UuidValue.withValidation(cardId),
);
if (card == null) {
return;
}
// Only the named columns change (ADR 0001, rule 1).
await protocol.Card.db.updateRow(
_db,
card.copyWith(
studysetId: protocol.UuidValue.withValidation(studysetId),
position: position,
),
columns: (t) => [t.studysetId, t.position],
);
_sync.requestSync();
}
@override @override
Future<void> blankRevisions(String cardId) async { Future<void> blankRevisions(String cardId) async {
final revisions = await protocol.CardRevision.db.find( final revisions = await protocol.CardRevision.db.find(

View file

@ -0,0 +1,191 @@
import 'package:halkyon_learn_flutter/src/core/japanese/kana_input.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// How the card browser orders the cards.
enum CardBrowserSort() {
/// The studyset's own order (studysets inside it after it, in theirs).
manual,
/// The newest first.
newest,
/// The oldest first.
oldest,
/// By the front, in kana order for words (by their reading).
alphabetical,
/// The soonest due first; new cards after them.
due,
}
/// Where a card stands, by its schedule and state, to show only such cards.
enum CardStatus() {
/// Never studied.
fresh,
/// In its learning or relearning steps.
learning,
/// Due now (studied before).
due,
/// Set aside until tomorrow.
buried,
/// Reported as wrong, not resolved yet.
reported,
}
/// What kind of card the browser shows.
enum CardBrowserKind() {
/// A front and its back.
frontBack,
/// A question with answers to choose from.
multipleChoice,
/// A word.
vocabulary,
/// Items to sort.
matching,
/// A region to point at on a map.
map;
/// The kind of [content].
static CardBrowserKind of(CardContent content) => switch (content.type) {
CardType.multipleChoice when content.map != null => map,
CardType.frontBack => frontBack,
CardType.multipleChoice => multipleChoice,
CardType.vocabulary => vocabulary,
CardType.matching => matching,
};
}
/// What the browser shows of the cards: those its `query` finds, of its
/// `kind` (any when null), with the tag `tagId` (any when null), and in any
/// of its `states` (any state when empty).
typedef CardBrowserFilter = ({
String query,
CardBrowserKind? kind,
String? tagId,
Set<CardStatus> states,
});
/// No filter: every card.
const CardBrowserFilter noCardFilter = (
query: '',
kind: null,
tagId: null,
states: {},
);
/// A card as the browser holds it: with the studyset it is in, its tags and
/// its schedule.
typedef BrowsedCard = ({
Card card,
String studysetId,
List<Tag> tags,
CardSchedule? schedule,
});
/// What a search looks through in [card]: its front and back, a word's
/// reading, meanings and senses, a choice's answers, its hints and
/// explanation, and the names of its [tags]; folded as [searchFolded]
/// does.
String searchTextOf(Card card, List<Tag> tags) {
final content = card.content;
return searchFolded(
[
content.front.plainText,
content.back.plainText,
content.reading,
...content.meanings,
for (final sense in content.senses) ...sense.glosses,
for (final answer in content.answers) answer.content.plainText,
for (final hint in content.hints) hint.plainText,
?content.explanation?.plainText,
for (final tag in tags) tag.name,
].join('\n'),
);
}
/// [text] as searches compare it: in lower case, katakana as hiragana, so
/// テレビ is found by てれび and Tokyo by tokyo.
String searchFolded(String text) => katakanaToHiragana(text.toLowerCase());
/// Whether a card whose [searchText] is [searchTextOf] holds [query]: as
/// typed, or romaji typed for its kana (`taberu` finds たべる).
bool matchesQuery(String searchText, String query) {
final typed = searchFolded(query.trim());
if (typed.isEmpty) {
return true;
}
return searchText.contains(typed) ||
searchText.contains(romajiToHiragana(typed, finished: true));
}
/// Whether [card] is in [state] at [now], the local day having begun at
/// [startOfToday].
bool isInState(
BrowsedCard card,
CardStatus state, {
required DateTime now,
required DateTime startOfToday,
}) {
final schedule = card.schedule;
return switch (state) {
CardStatus.fresh => schedule == null || schedule.isNew,
CardStatus.learning => schedule?.isLearning ?? false,
CardStatus.due => switch (schedule?.due) {
final due? => !due.isAfter(now),
null => false,
},
CardStatus.buried => card.card.isBuried(startOfToday),
CardStatus.reported => card.card.content.report != null,
};
}
/// [cards], given in the studysets' own order, as [sort] orders them.
List<BrowsedCard> sortedCards(List<BrowsedCard> cards, CardBrowserSort sort) {
String alphabetic(BrowsedCard each) {
final content = each.card.content;
return searchFolded(
content.reading.trim().isNotEmpty
? content.reading
: content.front.plainText.trim(),
);
}
return switch (sort) {
CardBrowserSort.manual => cards,
// Ids are UUID v7: they sort by when the card was made.
CardBrowserSort.newest => [
...cards,
]..sort((a, b) => b.card.id.compareTo(a.card.id)),
CardBrowserSort.oldest => [
...cards,
]..sort((a, b) => a.card.id.compareTo(b.card.id)),
CardBrowserSort.alphabetical =>
[...cards]..sort(
(a, b) => switch (alphabetic(a).compareTo(alphabetic(b))) {
0 => a.card.id.compareTo(b.card.id),
final order => order,
},
),
CardBrowserSort.due =>
[...cards]..sort(
(a, b) => switch ((a.schedule?.due, b.schedule?.due)) {
(final x?, final y?) when x != y => x.compareTo(y),
(_?, null) => -1,
(null, _?) => 1,
_ => a.card.id.compareTo(b.card.id),
},
),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,264 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What the filter sheet comes to: a kind, a tag and states (none: any).
typedef CardBrowserChoice = ({
CardBrowserKind? kind,
String? tagId,
Set<CardStatus> states,
});
/// Asks how to filter the browser's cards, starting from [filter]: by kind,
/// by one of [tags], and by state. Completes with the choice once the
/// person applies it, or with null when they close the sheet, which keeps
/// the filter as it was.
Future<CardBrowserChoice?> showCardFilterSheet(
BuildContext context, {
required CardBrowserFilter filter,
required List<Tag> tags,
}) => showAdaptiveSheet<CardBrowserChoice>(
context,
builder: (context) => _FilterSheet(filter: filter, tags: tags),
);
class const _FilterSheet({
required final CardBrowserFilter filter,
required final List<Tag> tags,
}) extends StatefulWidget {
@override
State<_FilterSheet> createState() => _FilterSheetState();
}
class _FilterSheetState() extends State<_FilterSheet> {
late var _kind = widget.filter.kind;
late var _tagId = widget.filter.tagId;
late var _states = {...widget.filter.states};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
Widget heading(String text, {double top = 16}) => Padding(
padding: EdgeInsets.only(top: top, bottom: 8),
child: Text(text, style: theme.textTheme.labelLarge),
);
final tags = [...widget.tags]
..sort((a, b) => compareTagNames(a.name, b.name));
final cleared = _kind == null && _tagId == null && _states.isEmpty;
return SheetScaffold(
title: l10n.cardBrowserFilterTitle,
footer: Row(
children: [
TextButton(
onPressed: cleared
? null
: () => setState(() {
_kind = null;
_tagId = null;
_states = {};
}),
child: Text(l10n.cardBrowserClearAll),
),
const Spacer(),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop((kind: _kind, tagId: _tagId, states: _states)),
child: Text(l10n.cardBrowserApply),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(l10n.cardBrowserFilterType, top: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final kind in [null, ...CardBrowserKind.values])
ChoiceChip(
label: Text(_kindName(l10n, kind)),
selected: _kind == kind,
onSelected: (_) => setState(() => _kind = kind),
),
],
),
if (tags.isNotEmpty) ...[
heading(l10n.cardBrowserFilterTag),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: Text(l10n.cardBrowserAllTags),
selected: _tagId == null,
onSelected: (_) => setState(() => _tagId = null),
),
for (final tag in tags)
ChoiceChip(
label: Text(tag.name),
selected: _tagId == tag.id,
onSelected: (_) => setState(() => _tagId = tag.id),
),
],
),
],
heading(l10n.cardBrowserFilterStatus),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final status in CardStatus.values)
FilterChip(
avatar: status == CardStatus.reported
? const Icon(LucideIcons.flag, size: 16)
: null,
label: Text(_statusName(l10n, status)),
selected: _states.contains(status),
onSelected: (on) => setState(
() => on ? _states.add(status) : _states.remove(status),
),
),
],
),
],
),
);
}
}
String _kindName(AppLocalizations l10n, CardBrowserKind? kind) =>
switch (kind) {
null => l10n.cardBrowserTypeAll,
CardBrowserKind.frontBack => l10n.cardBrowserTypeFrontBack,
CardBrowserKind.multipleChoice => l10n.cardBrowserTypeMultipleChoice,
CardBrowserKind.vocabulary => l10n.cardBrowserTypeVocabulary,
CardBrowserKind.matching => l10n.cardBrowserTypeMatching,
CardBrowserKind.map => l10n.cardBrowserTypeMap,
};
String _statusName(AppLocalizations l10n, CardStatus status) =>
switch (status) {
CardStatus.fresh => l10n.cardBrowserStatusNew,
CardStatus.learning => l10n.cardBrowserStatusLearning,
CardStatus.due => l10n.cardBrowserStatusDue,
CardStatus.buried => l10n.cardBrowserStatusBuried,
CardStatus.reported => l10n.cardBrowserStatusReported,
};
/// Asks which tags to give [count] chosen cards, among all the person's;
/// or, with [removing], which of those (the tags they have) to take away.
/// Completes with the ids chosen, or with null when the sheet closes.
Future<Set<String>?> showBulkTagSheet(
BuildContext context, {
required int count,
List<Tag>? removing,
}) => showAdaptiveSheet<Set<String>>(
context,
builder: (context) => BlocProvider(
create: (context) =>
TagsBloc(PersonScope.of(context).tags)..add(const TagsEventStarted()),
child: _BulkTagSheet(count: count, removing: removing),
),
);
class const _BulkTagSheet({required final int count, final List<Tag>? removing})
extends StatefulWidget {
@override
State<_BulkTagSheet> createState() => _BulkTagSheetState();
}
class _BulkTagSheetState() extends State<_BulkTagSheet> {
final _chosen = <String>{};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final removing = widget.removing;
final count = widget.count;
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
Widget chips(List<Tag> tags) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: tags.isEmpty
? Text(
removing == null
? l10n.cardBrowserNoTags
: l10n.cardBrowserNoTagsOnCards,
style: muted,
textAlign: TextAlign.center,
)
: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in tags)
SelectableTagChip(
tag: tag,
selected: _chosen.contains(tag.id),
onSelected: (on) => setState(
() => on ? _chosen.add(tag.id) : _chosen.remove(tag.id),
),
),
],
),
);
return SheetScaffold(
title: removing == null
? l10n.cardBrowserAddTagsTitle(count)
: l10n.cardBrowserRemoveTagsTitle(count),
heightFactor: 0.7,
footer: Row(
children: [
if (removing == null)
TextButton.icon(
onPressed: () => showManageTagsSheet(context),
icon: const Icon(LucideIcons.settings2, size: 16),
label: Text(l10n.tagPickerManage),
),
const Spacer(),
FilledButton.icon(
onPressed: _chosen.isEmpty
? null
: () => Navigator.of(context).pop({..._chosen}),
icon: const Icon(LucideIcons.tags, size: 18),
label: Text(
removing == null
? l10n.cardBrowserAddTo(count)
: l10n.cardBrowserRemoveFrom(count),
),
),
],
),
body: switch (removing) {
final tags? => chips(tags),
null => BlocBuilder<TagsBloc, TagsState>(
builder: (context, state) => switch (state) {
TagsStateLoading() => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingIndicator()),
),
TagsStateFailed() => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(l10n.tagsReadFailed, style: muted),
),
TagsStateLoaded(:final tagging) => chips(tagging.all),
},
),
},
);
}
}

View file

@ -28,12 +28,29 @@ Future<MoveDestination?> showMoveSheet(
required List<Studyset> all, required List<Studyset> all,
}) => showAdaptiveSheet<MoveDestination>( }) => showAdaptiveSheet<MoveDestination>(
context, context,
builder: (context) => _MoveSheet(studyset: studyset, all: all), builder: (context) => _MoveSheet(moving: studyset, all: all),
); );
/// Asks where to move [count] cards of the studyset [from], among [all]:
/// into another studyset, or into a new one; [from] itself, marked as the
/// current one, can't be chosen. Completes as [showMoveSheet] does (never
/// with the top level: cards belong in a studyset).
Future<MoveDestination?> showCardMoveSheet(
BuildContext context, {
required int count,
required Studyset from,
required List<Studyset> all,
}) => showAdaptiveSheet<MoveDestination>(
context,
builder: (context) => _MoveSheet(cardsFrom: from, cardCount: count, all: all),
);
/// Moves [moving], a studyset; or [cardCount] cards of [cardsFrom].
class const _MoveSheet({ class const _MoveSheet({
required final Studyset studyset,
required final List<Studyset> all, required final List<Studyset> all,
final Studyset? moving,
final Studyset? cardsFrom,
final int cardCount = 0,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
State<_MoveSheet> createState() => _MoveSheetState(); State<_MoveSheet> createState() => _MoveSheetState();
@ -48,8 +65,15 @@ class _MoveSheetState() extends State<_MoveSheet> {
/// The studysets whose children show. /// The studysets whose children show.
final _open = <String>{}; final _open = <String>{};
/// The studyset and everything inside it, which can't take it. /// The studyset and everything inside it, which can't take it; the
late final _excluded = subtreeOf(widget.studyset.id, widget.all).toSet(); /// studyset cards are in, which has them already.
late final _excluded = switch (widget.moving) {
final moving? => subtreeOf(moving.id, widget.all).toSet(),
null => {widget.cardsFrom!.id},
};
/// What is moved now: the studyset, or the one the cards are in.
Studyset get _current => widget.moving ?? widget.cardsFrom!;
Future<void> _create() async { Future<void> _create() async {
final content = await showStudysetSheet(context); final content = await showStudysetSheet(context);
@ -80,11 +104,17 @@ class _MoveSheetState() extends State<_MoveSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final current = widget.studyset.parentId; // Where it is now: a studyset's parent, or the cards' own studyset.
final current = switch (widget.moving) {
final moving? => moving.parentId,
null => widget.cardsFrom!.id,
};
final destination = _destination; final destination = _destination;
final canMove = final canMove =
destination != null && destination != null &&
(destination.into != null || destination.parentId != current); (destination.into != null ||
(destination.parentId != current &&
(widget.moving != null || destination.parentId != null)));
final roots = topLevel(widget.all); final roots = topLevel(widget.all);
final archived = [ final archived = [
for (final studyset in roots) for (final studyset in roots)
@ -92,7 +122,9 @@ class _MoveSheetState() extends State<_MoveSheet> {
]; ];
final newOne = _new; final newOne = _new;
return SheetScaffold( return SheetScaffold(
title: l10n.studysetMoveTitle, title: widget.moving == null
? l10n.cardBrowserMoveTitle(widget.cardCount)
: l10n.studysetMoveTitle,
footer: Row( footer: Row(
children: [ children: [
Expanded( Expanded(
@ -128,9 +160,9 @@ class _MoveSheetState() extends State<_MoveSheet> {
label: Text(l10n.studysetMoveNew), label: Text(l10n.studysetMoveNew),
), ),
), ),
// Only while it is inside another: at the top level already, it // Only for a studyset inside another: at the top level already, it
// would move nowhere. // would move nowhere, and cards need a studyset.
if (current != null) ...[ if (widget.moving != null && current != null) ...[
_Row( _Row(
depth: 0, depth: 0,
title: l10n.studysetMoveTopLevelRow, title: l10n.studysetMoveTopLevelRow,
@ -181,7 +213,7 @@ class _MoveSheetState() extends State<_MoveSheet> {
_Row( _Row(
depth: depth, depth: depth,
title: studyset.content.title, title: studyset.content.title,
current: studyset.id == widget.studyset.id, current: studyset.id == _current.id,
archived: studyset.isArchived, archived: studyset.isArchived,
selected: selected:
_destination?.into == null && _destination?.parentId == studyset.id, _destination?.into == null && _destination?.parentId == studyset.id,

View file

@ -22,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
@ -641,9 +642,18 @@ class _ContentState()
animation: _tabs, animation: _tabs,
builder: (context, _) => builder: (context, _) =>
switch (StudysetTab.values[_tabs.index]) { switch (StudysetTab.values[_tabs.index]) {
StudysetTab.flashcards => const Padding( StudysetTab.flashcards => Padding(
padding: EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: CardListToolbar(), child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Browse(
studysetId: widget.studysetId,
labelled: constraints.maxWidth >= 480,
),
const CardListToolbar(),
],
),
), ),
StudysetTab.paths => StudysetTab.paths =>
BlocSelector<PathListBloc, PathListState, int>( BlocSelector<PathListBloc, PathListState, int>(
@ -745,6 +755,49 @@ class _ContentState()
} }
} }
/// Browse beside the flashcards' tab, while it has cards: opens the card
/// browser of the studyset [studysetId], over everything inside it too
/// when there is something. [labelled] says Browse, else an icon alone.
class const _Browse({
required final String studysetId,
required final bool labelled,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasCards = context.select<CardListBloc, bool>(
(bloc) => switch (bloc.state) {
CardListStateLoaded(:final cards) => cards.isNotEmpty,
_ => false,
},
);
if (!hasCards) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
void open() => context.push(
RoutePaths.cardBrowser(
studysetId,
// The rail's list: the studysets inside this one.
subtree: switch (context.read<StudysetListBloc>().state) {
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
_ => false,
},
),
);
return labelled
? TextButton.icon(
onPressed: open,
icon: const Icon(LucideIcons.layoutGrid, size: 18),
label: Text(l10n.cardBrowserBrowse),
)
: IconButton(
onPressed: open,
icon: const Icon(LucideIcons.layoutGrid),
tooltip: l10n.cardBrowserBrowseTooltip,
);
}
}
/// Beside the tabs while a list that can be reordered shows, once it has /// Beside the tabs while a list that can be reordered shows, once it has
/// [count] two or more: Reorder, and Done while [reordering]; [labelled] /// [count] two or more: Reorder, and Done while [reordering]; [labelled]
/// with its word, else an icon alone whose [tooltip] says what it reorders. /// with its word, else an icon alone whose [tooltip] says what it reorders.

Some files were not shown because too many files have changed in this diff Show more