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:
parent
8b38fb51fe
commit
42c57bd40a
22 changed files with 4436 additions and 19 deletions
|
|
@ -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}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue