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
22 changed files with 4436 additions and 19 deletions

View file

@ -1181,5 +1181,83 @@
"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.",
"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.",
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
"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:
/// **'Don\'t suggest again'**
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

View file

@ -4332,4 +4332,363 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
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`).

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/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/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/dictionary/widget/dictionary_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
// one of them. Before the card route: its `new` would otherwise read as
// a card's id.

View file

@ -27,6 +27,12 @@ class DeviceSettingsBloc(
(event, emit) =>
_apply(emit, state.withPomodoroWhileStudying(event.shown)),
);
on<DeviceSettingsEventBrowserViewChosen>(
(event, emit) => _apply(
emit,
state.withBrowserView(event.studysetId, detailed: event.detailed),
),
);
}
Future<void> _apply(
@ -77,3 +83,13 @@ final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
@override
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.apa,
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) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -64,6 +68,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'language': ?settings.language,
'citationStyle': settings.citationStyle.name,
'pomodoroWhileStudying': settings.pomodoroWhileStudying,
'detailedBrowsers': [...settings.detailedBrowsers],
}),
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
/// app looks, the language it speaks, the style it cites sources in, and
/// whether studying shows the Pomodoro timer.
/// app looks, the language it speaks, the style it cites sources in,
/// 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,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -25,6 +26,10 @@ final class const DeviceSettings({
/// Whether a study session shows the Pomodoro timer's countdown.
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 {
/// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
@ -32,6 +37,7 @@ final class const DeviceSettings({
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
);
/// These settings with [language] instead; null for the device's.
@ -40,6 +46,7 @@ final class const DeviceSettings({
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
);
/// These settings with [citationStyle] instead.
@ -49,6 +56,7 @@ final class const DeviceSettings({
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
);
/// These settings with [shown] for the Pomodoro timer while studying.
@ -57,13 +65,28 @@ final class const DeviceSettings({
language: language,
citationStyle: citationStyle,
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
List<Object?> get props => [
appearance,
language,
citationStyle,
pomodoroWhileStudying,
detailedBrowsers,
];
}

View file

@ -33,6 +33,13 @@ abstract final class RoutePaths() {
queryParameters: query.isEmpty ? null : query,
).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.
static String newCard(String studysetId) =>
'/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.
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].
Future<void> blankRevisions(String cardId);

View file

@ -243,6 +243,17 @@ class CardRepository(final CardGateway _gateway) {
_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
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(Card card) => deleteById(card.id);

View file

@ -160,6 +160,31 @@ class SyncCardGateway(
_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
Future<void> blankRevisions(String cardId) async {
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,
}) => showAdaptiveSheet<MoveDestination>(
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({
required final Studyset studyset,
required final List<Studyset> all,
final Studyset? moving,
final Studyset? cardsFrom,
final int cardCount = 0,
}) extends StatefulWidget {
@override
State<_MoveSheet> createState() => _MoveSheetState();
@ -48,8 +65,15 @@ class _MoveSheetState() extends State<_MoveSheet> {
/// The studysets whose children show.
final _open = <String>{};
/// The studyset and everything inside it, which can't take it.
late final _excluded = subtreeOf(widget.studyset.id, widget.all).toSet();
/// The studyset and everything inside it, which can't take it; the
/// 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 {
final content = await showStudysetSheet(context);
@ -80,11 +104,17 @@ class _MoveSheetState() extends State<_MoveSheet> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.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 canMove =
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 archived = [
for (final studyset in roots)
@ -92,7 +122,9 @@ class _MoveSheetState() extends State<_MoveSheet> {
];
final newOne = _new;
return SheetScaffold(
title: l10n.studysetMoveTitle,
title: widget.moving == null
? l10n.cardBrowserMoveTitle(widget.cardCount)
: l10n.studysetMoveTitle,
footer: Row(
children: [
Expanded(
@ -128,9 +160,9 @@ class _MoveSheetState() extends State<_MoveSheet> {
label: Text(l10n.studysetMoveNew),
),
),
// Only while it is inside another: at the top level already, it
// would move nowhere.
if (current != null) ...[
// Only for a studyset inside another: at the top level already, it
// would move nowhere, and cards need a studyset.
if (widget.moving != null && current != null) ...[
_Row(
depth: 0,
title: l10n.studysetMoveTopLevelRow,
@ -181,7 +213,7 @@ class _MoveSheetState() extends State<_MoveSheet> {
_Row(
depth: depth,
title: studyset.content.title,
current: studyset.id == widget.studyset.id,
current: studyset.id == _current.id,
archived: studyset.isArchived,
selected:
_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/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/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_text_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
@ -641,9 +642,18 @@ class _ContentState()
animation: _tabs,
builder: (context, _) =>
switch (StudysetTab.values[_tabs.index]) {
StudysetTab.flashcards => const Padding(
padding: EdgeInsets.only(right: 16),
child: CardListToolbar(),
StudysetTab.flashcards => Padding(
padding: const EdgeInsets.only(right: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Browse(
studysetId: widget.studysetId,
labelled: constraints.maxWidth >= 480,
),
const CardListToolbar(),
],
),
),
StudysetTab.paths =>
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
/// [count] two or more: Reorder, and Done while [reordering]; [labelled]
/// with its word, else an icon alone whose [tooltip] says what it reorders.

View file

@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_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_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
CardContent word(String front, String reading, String meaning) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain(front),
back: RichContent.empty(),
reading: reading,
meanings: [meaning],
);
CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
void main() {
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late CardDeletions deletions;
late CardBrowserBloc bloc;
setUp(() {
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards);
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await tags.dispose();
await reviews.dispose();
await cards.dispose();
});
void start([List<String> studysets = const ['japanese']]) =>
bloc = CardBrowserBloc(cards, tags, reviews, deletions, studysets)
..add(const CardBrowserEventStarted());
Future<CardBrowserState> until(bool Function(CardBrowserState) test) =>
test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
List<String> fronts(CardBrowserState state) => [
for (final each in state.shown) each.card.content.front.plainText.trim(),
];
test('a search finds cards by their front, reading (katakana as '
'hiragana, romaji for kana), meanings and tags', () async {
final taberu = await cards.create('japanese', word('食べる', 'たべる', 'to eat'));
await cards.create('japanese', word('テレビ', 'てれび', 'television'));
await cards.create('japanese', card('hola', 'hello'));
await tags.setTagsOf(taberu, {await tags.create('Verb')});
start();
await until((s) => s.loaded && s.cards.length == 3);
Future<List<String>> search(String query) async {
bloc.add(CardBrowserEventQueryChanged(query));
return fronts(await until((s) => s.filter.query == query));
}
expect(await search('たべ'), ['食べる']);
expect(await search('taberu'), ['食べる']);
expect(await search('テレ'), ['テレビ']);
expect(await search('TELEVISION'), ['テレビ']);
expect(await search('verb'), ['食べる']);
expect(await search('hello'), ['hola']);
expect(await search(''), hasLength(3));
});
test('filters by kind and state, and sorts', () async {
await cards.create('japanese', word('猫', 'ねこ', 'cat'));
final studied = await cards.create('japanese', card('b', 'b'));
await cards.create('japanese', word('犬', 'いぬ', 'dog'));
await reviews.record(
cardId: studied,
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
start();
await until((s) => s.loaded && s.cards.length == 3);
bloc.add(
const CardBrowserEventFilterChanged(kind: CardBrowserKind.vocabulary),
);
expect(
fronts(await until((s) => s.filter.kind == CardBrowserKind.vocabulary)),
['猫', '犬'],
);
bloc.add(const CardBrowserEventSortChanged(CardBrowserSort.alphabetical));
expect(
fronts(
await until((s) => s.sort == CardBrowserSort.alphabetical),
),
['犬', '猫'],
);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.fresh}));
final fresh = await until((s) => s.filter.states.isNotEmpty);
expect(fronts(fresh), ['犬', '猫']);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.due}));
expect(
fronts(await until((s) => s.filter.states.contains(CardStatus.due))),
['b'],
);
bloc.add(const CardBrowserEventFiltersCleared());
expect(await until((s) => !s.isFiltered), isA<CardBrowserState>());
});
test('chooses cards one by one or as a range, and moves, tags, untags and '
'starts them afresh all at once', () async {
final ids = [
for (final front in ['a', 'b', 'c', 'd'])
await cards.create('japanese', card(front, front)),
];
final lesson = await tags.create('Lesson 1');
start();
await until((s) => s.loaded && s.cards.length == 4);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(ids[0]))
..add(CardBrowserEventCardSelected(ids[2], range: true));
final chosen = await until((s) => s.selected.length == 3);
expect(chosen.selected, {ids[0], ids[1], ids[2]});
bloc.add(CardBrowserEventTagsAdded({lesson}));
await until(
(s) => s.cards.where((each) => each.tags.isNotEmpty).length == 3,
);
bloc.add(CardBrowserEventTagsRemoved({lesson}));
await until((s) => s.cards.every((each) => each.tags.isEmpty));
await reviews.record(
cardId: ids[1],
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
bloc.add(const CardBrowserEventProgressReset());
await until(
(s) => s.cards.every((each) => each.schedule?.isNew ?? true),
);
bloc.add(const CardBrowserEventMoved('kana'));
final moved = await until((s) => s.cards.length == 1);
expect(moved.selected, isEmpty);
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[ids[0], ids[1], ids[2]],
);
});
test('a studyset and those inside it are browsed together, in tree '
'order', () async {
await cards.create('japanese', card('top', 'x'));
await cards.create('kana', card('inside', 'x'));
start(['japanese', 'kana']);
final state = await until((s) => s.loaded && s.cards.length == 2);
expect(fronts(state), ['top', 'inside']);
expect(state.shown.last.studysetId, 'kana');
});
test('a card whose deletion waits leaves the browser and the '
'selection', () async {
final gone = await cards.create('japanese', card('gone', 'x'));
await cards.create('japanese', card('stays', 'x'));
start();
await until((s) => s.loaded && s.cards.length == 2);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(gone));
await until((s) => s.selected.isNotEmpty);
deletions.delete(gone);
final state = await until((s) => s.cards.length == 1);
expect(state.selected, isEmpty);
});
}

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