feat(app): choose, manage and filter by tags
The card editor offers its tags: a picker that searches by the start of a name, creates a tag (name and colour) and chooses it, and saves the choice with the card. A sheet lists every tag with its count, to add, rename (merging into a tag of that name), recolour and delete them, with an undo. The card list shows each card's tags and narrows to one tag. The editor closed twice once tags changed after saving; it now closes on becoming done only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c2d4dfc8b6
commit
9dc9b6a5ed
20 changed files with 2076 additions and 63 deletions
|
|
@ -93,7 +93,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
|
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
|
||||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
|
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
|
||||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47).
|
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
|
||||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
|
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
@ -103,7 +103,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||||
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead.
|
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`TagRepository._serially`).
|
||||||
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
|
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
|
||||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||||
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
|
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
|
||||||
|
|
@ -128,7 +128,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
||||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,51 @@
|
||||||
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
||||||
"settingsAbout": "About Halkyon Learn",
|
"settingsAbout": "About Halkyon Learn",
|
||||||
|
|
||||||
|
"tagsReadFailed": "Couldn't read the tags.",
|
||||||
|
"tagPickerTitle": "Tags",
|
||||||
|
"tagPickerManage": "Manage tags",
|
||||||
|
"tagPickerSearchLabel": "Search or create a tag",
|
||||||
|
"tagPickerCreate": "Create \"{name}\"",
|
||||||
|
"@tagPickerCreate": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"tagSheetCreateTitle": "Create tag",
|
||||||
|
"tagSheetEditTitle": "Edit tag",
|
||||||
|
"tagSheetNameLabel": "Name",
|
||||||
|
"tagSheetNameHelper": "Stored and displayed in lowercase.",
|
||||||
|
"tagSheetNameExists": "The tag '{name}' exists already.",
|
||||||
|
"@tagSheetNameExists": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"tagSheetNameMerges": "There is a tag '{name}' already: saving merges this one into it.",
|
||||||
|
"@tagSheetNameMerges": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"tagSheetColor": "Color",
|
||||||
|
"tagSheetCreate": "Create",
|
||||||
|
"tagSheetSave": "Save",
|
||||||
|
"tagManageTitle": "Manage tags",
|
||||||
|
"tagManageEmpty": "No tags yet. Add one below.",
|
||||||
|
"tagManageAdd": "Add tag",
|
||||||
|
"tagManageRow": "{name} ({count})",
|
||||||
|
"@tagManageRow": {"description": "A tag in the list of tags, with how many flashcards have it.", "placeholders": {"name": {"type": "String"}, "count": {"type": "int"}}},
|
||||||
|
"tagManageEdit": "Edit tag",
|
||||||
|
"tagManageDelete": "Delete tag",
|
||||||
|
"tagDeleteTitle": "Delete the tag '{name}'?",
|
||||||
|
"@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"tagDeleteMessage": "{count, plural, =0{It isn't on any flashcards.} =1{It is removed from 1 flashcard, on all your devices.} other{It is removed from {count} flashcards, on all your devices.}}",
|
||||||
|
"@tagDeleteMessage": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"tagDeleteConfirm": "Delete",
|
||||||
|
"tagDeleted": "Deleted the tag '{name}'",
|
||||||
|
"@tagDeleted": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"cardTagsLabel": "Tags",
|
||||||
|
"cardTagsAdd": "Add tags",
|
||||||
|
"cardTagsEdit": "Edit tags",
|
||||||
|
"cardFilterTooltip": "Filter",
|
||||||
|
"cardFilterTitle": "Filter",
|
||||||
|
"cardFilterTag": "Tag",
|
||||||
|
"cardFilterAllTags": "All tags",
|
||||||
|
"cardFilterNoTags": "None of these flashcards has a tag yet.",
|
||||||
|
"cardFilterClear": "Clear all",
|
||||||
|
"cardFilterApply": "Apply",
|
||||||
|
"cardFilterNothing": "No flashcards have this tag.",
|
||||||
|
"cardListMore": "More",
|
||||||
|
"cardListManageTags": "Manage tags",
|
||||||
|
|
||||||
"commonCancel": "Cancel",
|
"commonCancel": "Cancel",
|
||||||
"commonClose": "Close",
|
"commonClose": "Close",
|
||||||
"commonUndo": "Undo",
|
"commonUndo": "Undo",
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,228 @@ abstract class AppLocalizations {
|
||||||
/// **'About Halkyon Learn'**
|
/// **'About Halkyon Learn'**
|
||||||
String get settingsAbout;
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @tagsReadFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t read the tags.'**
|
||||||
|
String get tagsReadFailed;
|
||||||
|
|
||||||
|
/// No description provided for @tagPickerTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tags'**
|
||||||
|
String get tagPickerTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tagPickerManage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Manage tags'**
|
||||||
|
String get tagPickerManage;
|
||||||
|
|
||||||
|
/// No description provided for @tagPickerSearchLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search or create a tag'**
|
||||||
|
String get tagPickerSearchLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tagPickerCreate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create \"{name}\"'**
|
||||||
|
String tagPickerCreate(String name);
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetCreateTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create tag'**
|
||||||
|
String get tagSheetCreateTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetEditTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit tag'**
|
||||||
|
String get tagSheetEditTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetNameLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Name'**
|
||||||
|
String get tagSheetNameLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetNameHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stored and displayed in lowercase.'**
|
||||||
|
String get tagSheetNameHelper;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetNameExists.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The tag \'{name}\' exists already.'**
|
||||||
|
String tagSheetNameExists(String name);
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetNameMerges.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'There is a tag \'{name}\' already: saving merges this one into it.'**
|
||||||
|
String tagSheetNameMerges(String name);
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetColor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Color'**
|
||||||
|
String get tagSheetColor;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetCreate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create'**
|
||||||
|
String get tagSheetCreate;
|
||||||
|
|
||||||
|
/// No description provided for @tagSheetSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get tagSheetSave;
|
||||||
|
|
||||||
|
/// No description provided for @tagManageTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Manage tags'**
|
||||||
|
String get tagManageTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tagManageEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No tags yet. Add one below.'**
|
||||||
|
String get tagManageEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @tagManageAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add tag'**
|
||||||
|
String get tagManageAdd;
|
||||||
|
|
||||||
|
/// A tag in the list of tags, with how many flashcards have it.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{name} ({count})'**
|
||||||
|
String tagManageRow(String name, int count);
|
||||||
|
|
||||||
|
/// No description provided for @tagManageEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit tag'**
|
||||||
|
String get tagManageEdit;
|
||||||
|
|
||||||
|
/// No description provided for @tagManageDelete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete tag'**
|
||||||
|
String get tagManageDelete;
|
||||||
|
|
||||||
|
/// No description provided for @tagDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete the tag \'{name}\'?'**
|
||||||
|
String tagDeleteTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @tagDeleteMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =0{It isn\'t on any flashcards.} =1{It is removed from 1 flashcard, on all your devices.} other{It is removed from {count} flashcards, on all your devices.}}'**
|
||||||
|
String tagDeleteMessage(int count);
|
||||||
|
|
||||||
|
/// No description provided for @tagDeleteConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete'**
|
||||||
|
String get tagDeleteConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @tagDeleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deleted the tag \'{name}\''**
|
||||||
|
String tagDeleted(String name);
|
||||||
|
|
||||||
|
/// No description provided for @cardTagsLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tags'**
|
||||||
|
String get cardTagsLabel;
|
||||||
|
|
||||||
|
/// No description provided for @cardTagsAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add tags'**
|
||||||
|
String get cardTagsAdd;
|
||||||
|
|
||||||
|
/// No description provided for @cardTagsEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit tags'**
|
||||||
|
String get cardTagsEdit;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filter'**
|
||||||
|
String get cardFilterTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filter'**
|
||||||
|
String get cardFilterTitle;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterTag.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tag'**
|
||||||
|
String get cardFilterTag;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterAllTags.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All tags'**
|
||||||
|
String get cardFilterAllTags;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterNoTags.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None of these flashcards has a tag yet.'**
|
||||||
|
String get cardFilterNoTags;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterClear.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear all'**
|
||||||
|
String get cardFilterClear;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterApply.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Apply'**
|
||||||
|
String get cardFilterApply;
|
||||||
|
|
||||||
|
/// No description provided for @cardFilterNothing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No flashcards have this tag.'**
|
||||||
|
String get cardFilterNothing;
|
||||||
|
|
||||||
|
/// No description provided for @cardListMore.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'More'**
|
||||||
|
String get cardListMore;
|
||||||
|
|
||||||
|
/// No description provided for @cardListManageTags.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Manage tags'**
|
||||||
|
String get cardListManageTags;
|
||||||
|
|
||||||
/// No description provided for @commonCancel.
|
/// No description provided for @commonCancel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,138 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About Halkyon Learn';
|
String get settingsAbout => 'About Halkyon Learn';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagsReadFailed => 'Couldn\'t read the tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagPickerTitle => 'Tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagPickerManage => 'Manage tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagPickerSearchLabel => 'Search or create a tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagPickerCreate(String name) {
|
||||||
|
return 'Create \"$name\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetCreateTitle => 'Create tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetEditTitle => 'Edit tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetNameLabel => 'Name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetNameHelper => 'Stored and displayed in lowercase.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagSheetNameExists(String name) {
|
||||||
|
return 'The tag \'$name\' exists already.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagSheetNameMerges(String name) {
|
||||||
|
return 'There is a tag \'$name\' already: saving merges this one into it.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetColor => 'Color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetCreate => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagSheetSave => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagManageTitle => 'Manage tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagManageEmpty => 'No tags yet. Add one below.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagManageAdd => 'Add tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagManageRow(String name, int count) {
|
||||||
|
return '$name ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagManageEdit => 'Edit tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagManageDelete => 'Delete tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagDeleteTitle(String name) {
|
||||||
|
return 'Delete the tag \'$name\'?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagDeleteMessage(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'It is removed from $count flashcards, on all your devices.',
|
||||||
|
one: 'It is removed from 1 flashcard, on all your devices.',
|
||||||
|
zero: 'It isn\'t on any flashcards.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tagDeleteConfirm => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tagDeleted(String name) {
|
||||||
|
return 'Deleted the tag \'$name\'';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardTagsLabel => 'Tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardTagsAdd => 'Add tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardTagsEdit => 'Edit tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterTooltip => 'Filter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterTitle => 'Filter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterTag => 'Tag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterAllTags => 'All tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterNoTags => 'None of these flashcards has a tag yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterClear => 'Clear all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterApply => 'Apply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardFilterNothing => 'No flashcards have this tag.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardListMore => 'More';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardListManageTags => 'Manage tags';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get commonCancel => 'Cancel';
|
String get commonCancel => 'Cancel';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,23 +105,29 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => BlocProvider(
|
||||||
create: (context) => CardEditBloc(
|
create: (context) => CardEditBloc(
|
||||||
PersonScope.of(context).cards,
|
PersonScope.of(context).cards,
|
||||||
|
PersonScope.of(context).tags,
|
||||||
state.pathParameters['studysetId']!,
|
state.pathParameters['studysetId']!,
|
||||||
null,
|
null,
|
||||||
),
|
)..add(const CardEditEventStarted()),
|
||||||
child: const CardScreen(card: null),
|
child: const CardScreen(card: null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/studysets/:studysetId/cards/:cardId',
|
path: '/studysets/:studysetId/cards/:cardId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final cards = PersonScope.of(context).cards;
|
final person = PersonScope.of(context);
|
||||||
final studysetId = state.pathParameters['studysetId']!;
|
final studysetId = state.pathParameters['studysetId']!;
|
||||||
return FoundById<Card>(
|
return FoundById<Card>(
|
||||||
id: state.pathParameters['cardId']!,
|
id: state.pathParameters['cardId']!,
|
||||||
items: () => cards.watch(studysetId),
|
items: () => person.cards.watch(studysetId),
|
||||||
idOf: (card) => card.id,
|
idOf: (card) => card.id,
|
||||||
builder: (card, _) => BlocProvider(
|
builder: (card, _) => BlocProvider(
|
||||||
create: (context) => CardEditBloc(cards, studysetId, card),
|
create: (context) => CardEditBloc(
|
||||||
|
person.cards,
|
||||||
|
person.tags,
|
||||||
|
studysetId,
|
||||||
|
card,
|
||||||
|
)..add(const CardEditEventStarted()),
|
||||||
child: CardScreen(card: card),
|
child: CardScreen(card: card),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,10 @@ class const SheetScaffold({
|
||||||
/// Below the content, always in view.
|
/// Below the content, always in view.
|
||||||
final Widget? footer,
|
final Widget? footer,
|
||||||
|
|
||||||
|
/// Beside the title, before the close button: an action on the sheet as a
|
||||||
|
/// whole, such as managing what it lists.
|
||||||
|
final Widget? action,
|
||||||
|
|
||||||
/// What the close button does; by default it asks the sheet's route to
|
/// What the close button does; by default it asks the sheet's route to
|
||||||
/// close, which a `PopScope` in the sheet can stop (to ask before
|
/// close, which a `PopScope` in the sheet can stop (to ask before
|
||||||
/// discarding changes, say).
|
/// discarding changes, say).
|
||||||
|
|
@ -106,6 +110,11 @@ class const SheetScaffold({
|
||||||
/// The share of the window's height a bottom sheet takes; it hugs its
|
/// The share of the window's height a bottom sheet takes; it hugs its
|
||||||
/// content when null.
|
/// content when null.
|
||||||
final double? heightFactor,
|
final double? heightFactor,
|
||||||
|
|
||||||
|
/// Whether snack bars shown from inside the sheet show on it, where they
|
||||||
|
/// can be acted on (an Undo, say); those of the screens below would sit
|
||||||
|
/// behind its barrier. The sheet then always takes its largest height.
|
||||||
|
final bool hasSnackBars = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
/// The widest a sheet grows as a dialog.
|
/// The widest a sheet grows as a dialog.
|
||||||
|
|
@ -126,7 +135,7 @@ class const SheetScaffold({
|
||||||
: MainAxisSize.max,
|
: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_Header(title: title, onClose: onClose),
|
_Header(title: title, action: action, onClose: onClose),
|
||||||
// Loose, so a short body keeps the sheet short; tight at a fixed
|
// Loose, so a short body keeps the sheet short; tight at a fixed
|
||||||
// height, so the footer sits at the bottom.
|
// height, so the footer sits at the bottom.
|
||||||
if (!isDialog && heightFactor != null)
|
if (!isDialog && heightFactor != null)
|
||||||
|
|
@ -136,6 +145,16 @@ class const SheetScaffold({
|
||||||
if (footer case final footer?) ...[const SizedBox(height: 12), footer],
|
if (footer case final footer?) ...[const SizedBox(height: 12), footer],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
final content = hasSnackBars
|
||||||
|
? ScaffoldMessenger(
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
// The sheet keeps clear of the keyboard itself.
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
|
body: column,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: column;
|
||||||
if (isDialog) {
|
if (isDialog) {
|
||||||
// The dialog keeps clear of the window's edges and the keyboard
|
// The dialog keeps clear of the window's edges and the keyboard
|
||||||
// itself; insetting here as well would count them twice.
|
// itself; insetting here as well would count them twice.
|
||||||
|
|
@ -146,7 +165,7 @@ class const SheetScaffold({
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 20),
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 20),
|
||||||
child: column,
|
child: content,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -176,11 +195,11 @@ class const SheetScaffold({
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: math.min(media.size.height * 0.85, available),
|
maxHeight: math.min(media.size.height * 0.85, available),
|
||||||
),
|
),
|
||||||
child: column,
|
child: content,
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
height: math.min(media.size.height * heightFactor, available),
|
height: math.min(media.size.height * heightFactor, available),
|
||||||
child: column,
|
child: content,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -193,6 +212,7 @@ class const SheetScaffold({
|
||||||
/// taller than the window, and a dialog can't be swiped at all.
|
/// taller than the window, and a dialog can't be swiped at all.
|
||||||
class const _Header({
|
class const _Header({
|
||||||
required final String title,
|
required final String title,
|
||||||
|
required final Widget? action,
|
||||||
required final VoidCallback? onClose,
|
required final VoidCallback? onClose,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -206,6 +226,7 @@ class const _Header({
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
?action,
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: AppLocalizations.of(context).commonClose,
|
tooltip: AppLocalizations.of(context).commonClose,
|
||||||
icon: const Icon(LucideIcons.x),
|
icon: const Icon(LucideIcons.x),
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
|
|
||||||
/// Creates a card, or edits one: keeps the changes until they are saved, has
|
/// Creates a card, or edits one: keeps the changes to its sides and tags
|
||||||
/// the person choose a version for every side devices changed differently,
|
/// until they are saved, has the person choose a version for every side
|
||||||
/// and deletes on request.
|
/// devices changed differently, and deletes on request.
|
||||||
class CardEditBloc(
|
class CardEditBloc(
|
||||||
final CardRepository _cards,
|
final CardRepository _cards,
|
||||||
|
final TagRepository _tags,
|
||||||
final String _studysetId,
|
final String _studysetId,
|
||||||
Card? card,
|
Card? card,
|
||||||
) extends Bloc<CardEditEvent, CardEditState> {
|
) extends Bloc<CardEditEvent, CardEditState> {
|
||||||
|
|
@ -20,6 +23,29 @@ class CardEditBloc(
|
||||||
card: card,
|
card: card,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
on<CardEditEventStarted>(
|
||||||
|
(event, emit) => emit.forEach(
|
||||||
|
_tags.watch(),
|
||||||
|
onData: (tagging) {
|
||||||
|
// The card's tags as stored, once; later changes elsewhere don't
|
||||||
|
// overwrite the person's choice.
|
||||||
|
final stored = switch (state.card) {
|
||||||
|
final card? => {for (final tag in tagging.of(card.id)) tag.id},
|
||||||
|
null => <String>{},
|
||||||
|
};
|
||||||
|
return state.copyWith(
|
||||||
|
tagging: tagging,
|
||||||
|
savedTagIds: state.savedTagIds ?? stored,
|
||||||
|
tagIds: state.tagIds ?? stored,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Without tags the card can still be edited.
|
||||||
|
onError: (_, _) => state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<CardEditEventTagsChanged>(
|
||||||
|
(event, emit) => emit(state.copyWith(tagIds: event.tagIds)),
|
||||||
|
);
|
||||||
on<CardEditEventSideChanged>(
|
on<CardEditEventSideChanged>(
|
||||||
(event, emit) => emit(_withSide(event.side, event.content)),
|
(event, emit) => emit(_withSide(event.side, event.content)),
|
||||||
);
|
);
|
||||||
|
|
@ -42,10 +68,17 @@ class CardEditBloc(
|
||||||
if (!state.canSave) {
|
if (!state.canSave) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final String id;
|
||||||
if (state.card case final card?) {
|
if (state.card case final card?) {
|
||||||
await _cards.save(card, state.content);
|
id = card.id;
|
||||||
|
if (state.contentChanged) {
|
||||||
|
await _cards.save(card, state.content);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await _cards.create(_studysetId, state.content);
|
id = await _cards.create(_studysetId, state.content);
|
||||||
|
}
|
||||||
|
if (state.tagIds case final tagIds? when state.tagsChanged) {
|
||||||
|
await _tags.setTagsOf(id, tagIds);
|
||||||
}
|
}
|
||||||
emit(state.copyWith(isDone: true));
|
emit(state.copyWith(isDone: true));
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +100,18 @@ sealed class const CardEditEvent() with Equatable {
|
||||||
List<Object?> get props => const [];
|
List<Object?> get props => const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The editor opens: follow the tags, to show and choose the card's.
|
||||||
|
final class const CardEditEventStarted() extends CardEditEvent;
|
||||||
|
|
||||||
|
/// The person chose the tags [tagIds] for the card.
|
||||||
|
final class const CardEditEventTagsChanged(
|
||||||
|
/// The ids of the tags chosen.
|
||||||
|
final Set<String> tagIds,
|
||||||
|
) extends CardEditEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [tagIds];
|
||||||
|
}
|
||||||
|
|
||||||
/// [side] was edited to [content].
|
/// [side] was edited to [content].
|
||||||
final class const CardEditEventSideChanged(
|
final class const CardEditEventSideChanged(
|
||||||
/// The side edited.
|
/// The side edited.
|
||||||
|
|
@ -110,19 +155,43 @@ final class const CardEditState({
|
||||||
|
|
||||||
/// Saved or deleted; the screen closes then.
|
/// Saved or deleted; the screen closes then.
|
||||||
final bool isDone = false,
|
final bool isDone = false,
|
||||||
|
|
||||||
|
/// The person's tags, to show the chosen ones by name and colour.
|
||||||
|
final Tagging tagging = Tagging.empty,
|
||||||
|
|
||||||
|
/// The ids of the tags the card has as stored; null until read.
|
||||||
|
final Set<String>? savedTagIds,
|
||||||
|
|
||||||
|
/// The ids of the tags chosen; null until the stored ones are read.
|
||||||
|
final Set<String>? tagIds,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// The sides devices changed differently that still need a choice.
|
/// The sides devices changed differently that still need a choice.
|
||||||
Set<CardSide> get unresolvedSides => {
|
Set<CardSide> get unresolvedSides => {
|
||||||
...?card?.conflicts.keys,
|
...?card?.conflicts.keys,
|
||||||
}.difference(resolvedSides);
|
}.difference(resolvedSides);
|
||||||
|
|
||||||
|
/// Whether a stored card's sides would be stored anew: changed, or in
|
||||||
|
/// conflict.
|
||||||
|
bool get contentChanged => switch (card) {
|
||||||
|
null => true,
|
||||||
|
final card => content != card.content || card.hasConflict,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether other tags are chosen than the card has.
|
||||||
|
bool get tagsChanged => switch ((tagIds, savedTagIds)) {
|
||||||
|
(final chosen?, final saved?) =>
|
||||||
|
chosen.length != saved.length || !chosen.containsAll(saved),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
/// Whether saving would store something: every conflicted side settled,
|
/// Whether saving would store something: every conflicted side settled,
|
||||||
/// and a new card with content or a stored one changed or in conflict.
|
/// and a new card with content, or a stored one with changes or in
|
||||||
|
/// conflict.
|
||||||
bool get canSave =>
|
bool get canSave =>
|
||||||
unresolvedSides.isEmpty &&
|
unresolvedSides.isEmpty &&
|
||||||
switch (card) {
|
switch (card) {
|
||||||
null => content != CardContent.empty(),
|
null => content != CardContent.empty(),
|
||||||
final card => content != card.content || card.hasConflict,
|
_ => contentChanged || tagsChanged,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// This state with some fields replaced.
|
/// This state with some fields replaced.
|
||||||
|
|
@ -130,13 +199,27 @@ final class const CardEditState({
|
||||||
CardContent? content,
|
CardContent? content,
|
||||||
Set<CardSide>? resolvedSides,
|
Set<CardSide>? resolvedSides,
|
||||||
bool? isDone,
|
bool? isDone,
|
||||||
|
Tagging? tagging,
|
||||||
|
Set<String>? savedTagIds,
|
||||||
|
Set<String>? tagIds,
|
||||||
}) => CardEditState(
|
}) => CardEditState(
|
||||||
content: content ?? this.content,
|
content: content ?? this.content,
|
||||||
card: card,
|
card: card,
|
||||||
resolvedSides: resolvedSides ?? this.resolvedSides,
|
resolvedSides: resolvedSides ?? this.resolvedSides,
|
||||||
isDone: isDone ?? this.isDone,
|
isDone: isDone ?? this.isDone,
|
||||||
|
tagging: tagging ?? this.tagging,
|
||||||
|
savedTagIds: savedTagIds ?? this.savedTagIds,
|
||||||
|
tagIds: tagIds ?? this.tagIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [content, card, resolvedSides, isDone];
|
List<Object?> get props => [
|
||||||
|
content,
|
||||||
|
card,
|
||||||
|
resolvedSides,
|
||||||
|
isDone,
|
||||||
|
tagging,
|
||||||
|
savedTagIds,
|
||||||
|
tagIds,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,72 @@ import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.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';
|
||||||
|
|
||||||
/// Shows a studyset's cards in order and moves them; the list follows every
|
/// Shows a studyset's cards in order with their tags, all of them or those
|
||||||
/// change, local or synced.
|
/// with one tag, and moves them; the list follows every change, local or
|
||||||
class CardListBloc(final CardRepository _cards, final String _studysetId)
|
/// synced.
|
||||||
extends Bloc<CardListEvent, CardListState> {
|
class CardListBloc(
|
||||||
|
final CardRepository _cards,
|
||||||
|
final TagRepository _tags,
|
||||||
|
final String _studysetId,
|
||||||
|
) extends Bloc<CardListEvent, CardListState> {
|
||||||
this : super(const CardListStateLoading()) {
|
this : super(const CardListStateLoading()) {
|
||||||
on<CardListEventStarted>(_onStarted);
|
on<CardListEventStarted>(_onStarted);
|
||||||
on<CardListEventMoved>(_onMoved);
|
on<CardListEventMoved>(_onMoved);
|
||||||
|
on<CardListEventTagFilterChanged>((event, emit) {
|
||||||
|
_tagFilter = event.tagId;
|
||||||
|
if (_loaded() case final loaded?) {
|
||||||
|
emit(loaded);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Card>? _cardsNow;
|
||||||
|
var _tagging = Tagging.empty;
|
||||||
|
String? _tagFilter;
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
CardListEventStarted event,
|
CardListEventStarted event,
|
||||||
Emitter<CardListState> emit,
|
Emitter<CardListState> emit,
|
||||||
) => emit.forEach(
|
) async {
|
||||||
_cards.watch(_studysetId),
|
await Future.wait([
|
||||||
onData: CardListStateLoaded.new,
|
emit.forEach(
|
||||||
onError: (_, _) => const CardListStateFailed(),
|
_cards.watch(_studysetId),
|
||||||
);
|
onData: (cards) {
|
||||||
|
_cardsNow = cards;
|
||||||
|
return _loaded()!;
|
||||||
|
},
|
||||||
|
onError: (_, _) => const CardListStateFailed(),
|
||||||
|
),
|
||||||
|
emit.forEach(
|
||||||
|
_tags.watch(),
|
||||||
|
onData: (tagging) {
|
||||||
|
_tagging = tagging;
|
||||||
|
return _loaded() ?? state;
|
||||||
|
},
|
||||||
|
// Without tags the cards still show.
|
||||||
|
onError: (_, _) => state,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onMoved(
|
Future<void> _onMoved(
|
||||||
CardListEventMoved event,
|
CardListEventMoved event,
|
||||||
Emitter<CardListState> emit,
|
Emitter<CardListState> emit,
|
||||||
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
|
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
|
||||||
|
|
||||||
|
/// The list as it stands, or null while the cards are being read.
|
||||||
|
CardListStateLoaded? _loaded() => switch (_cardsNow) {
|
||||||
|
final cards? => CardListStateLoaded(
|
||||||
|
cards,
|
||||||
|
tagging: _tagging,
|
||||||
|
tagFilter: _tagFilter,
|
||||||
|
),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What happens to the list of cards.
|
/// What happens to the list of cards.
|
||||||
|
|
@ -33,7 +76,7 @@ sealed class const CardListEvent() with Equatable {
|
||||||
List<Object?> get props => const [];
|
List<Object?> get props => const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list opens: follow the studyset's cards.
|
/// The list opens: follow the studyset's cards and the tags.
|
||||||
final class const CardListEventStarted() extends CardListEvent;
|
final class const CardListEventStarted() extends CardListEvent;
|
||||||
|
|
||||||
/// Move [card] to [toIndex].
|
/// Move [card] to [toIndex].
|
||||||
|
|
@ -48,6 +91,15 @@ final class const CardListEventMoved(
|
||||||
List<Object?> get props => [card, toIndex];
|
List<Object?> get props => [card, toIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show only the cards with the tag [tagId], or all when it is null.
|
||||||
|
final class const CardListEventTagFilterChanged(
|
||||||
|
/// The tag's id.
|
||||||
|
final String? tagId,
|
||||||
|
) extends CardListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [tagId];
|
||||||
|
}
|
||||||
|
|
||||||
/// A studyset's cards, as the list shows them.
|
/// A studyset's cards, as the list shows them.
|
||||||
sealed class const CardListState() with Equatable {
|
sealed class const CardListState() with Equatable {
|
||||||
@override
|
@override
|
||||||
|
|
@ -60,11 +112,48 @@ final class const CardListStateLoading() extends CardListState;
|
||||||
/// The cards couldn't be read; the next change tries again.
|
/// The cards couldn't be read; the next change tries again.
|
||||||
final class const CardListStateFailed() extends CardListState;
|
final class const CardListStateFailed() extends CardListState;
|
||||||
|
|
||||||
/// The cards, in order.
|
/// The cards, in order, with their tags.
|
||||||
final class const CardListStateLoaded(
|
final class const CardListStateLoaded(
|
||||||
/// The cards of the studyset on this device.
|
/// The cards of the studyset on this device.
|
||||||
final List<Card> cards,
|
final List<Card> cards, {
|
||||||
) extends CardListState {
|
|
||||||
|
/// The person's tags and which cards have them.
|
||||||
|
final Tagging tagging = Tagging.empty,
|
||||||
|
|
||||||
|
/// The id of the tag the list is narrowed to; null for all cards.
|
||||||
|
final String? tagFilter,
|
||||||
|
}) extends CardListState {
|
||||||
|
/// The tag the list is narrowed to, while it exists.
|
||||||
|
Tag? get filter => switch (tagFilter) {
|
||||||
|
final id? => tagging.byId(id),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The cards shown: those with the [filter] tag, or all.
|
||||||
|
List<Card> get shown {
|
||||||
|
final filter = this.filter;
|
||||||
|
if (filter == null) {
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
final tagged = tagging.cardsWith(filter.id);
|
||||||
|
return [
|
||||||
|
for (final card in cards)
|
||||||
|
if (tagged.contains(card.id)) card,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tags any of the cards has, by name: those to filter by.
|
||||||
|
List<Tag> get tagsInUse {
|
||||||
|
final ids = {
|
||||||
|
for (final card in cards)
|
||||||
|
for (final tag in tagging.of(card.id)) tag.id,
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
for (final tag in tagging.all)
|
||||||
|
if (ids.contains(tag.id)) tag,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [cards];
|
List<Object?> get props => [cards, tagging, tagFilter];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,29 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.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';
|
||||||
|
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';
|
||||||
// Material's Card widget would clash with the domain's Card.
|
// Material's Card widget would clash with the domain's Card.
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
||||||
/// A studyset's cards: in order, draggable, each opening its editor. Lives
|
/// A studyset's cards: in order, draggable, each with its tags and opening
|
||||||
/// inside a scrolling screen; adding and studying are the screen's.
|
/// its editor, all of them or those with one tag. Lives inside a scrolling
|
||||||
|
/// screen; adding and studying are the screen's.
|
||||||
class const CardList({
|
class const CardList({
|
||||||
/// The person's cards.
|
/// The person's cards.
|
||||||
required final CardRepository cards,
|
required final CardRepository cards,
|
||||||
|
|
||||||
|
/// The person's tags.
|
||||||
|
required final TagRepository tags,
|
||||||
|
|
||||||
/// The studyset whose cards these are.
|
/// The studyset whose cards these are.
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -21,22 +32,174 @@ class const CardList({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => BlocProvider(
|
Widget build(BuildContext context) => BlocProvider(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
CardListBloc(cards, studysetId)..add(const CardListEventStarted()),
|
CardListBloc(cards, tags, studysetId)
|
||||||
|
..add(const CardListEventStarted()),
|
||||||
child: BlocBuilder<CardListBloc, CardListState>(
|
child: BlocBuilder<CardListBloc, CardListState>(
|
||||||
builder: (context, state) => switch (state) {
|
builder: (context, state) => switch (state) {
|
||||||
CardListStateLoading() => const SizedBox.shrink(),
|
CardListStateLoading() => const SizedBox.shrink(),
|
||||||
CardListStateFailed() => const Text("Couldn't read the cards."),
|
CardListStateFailed() => const Text("Couldn't read the cards."),
|
||||||
CardListStateLoaded(cards: []) => const _NoCards(),
|
CardListStateLoaded(cards: []) => const _NoCards(),
|
||||||
CardListStateLoaded(cards: final list) => _Cards(
|
final CardListStateLoaded loaded => Column(
|
||||||
cards,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
studysetId,
|
children: [
|
||||||
list,
|
_Toolbar(state: loaded),
|
||||||
|
if (loaded.shown.isEmpty)
|
||||||
|
const _NothingTagged()
|
||||||
|
else
|
||||||
|
_Cards(
|
||||||
|
cards,
|
||||||
|
studysetId,
|
||||||
|
loaded.shown,
|
||||||
|
loaded.tagging,
|
||||||
|
reorderable: loaded.filter == null,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Above the cards: narrowing them to a tag, and managing the tags.
|
||||||
|
class const _Toolbar({required final CardListStateLoaded state})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.cardFilterTooltip,
|
||||||
|
icon: Icon(
|
||||||
|
LucideIcons.listFilter,
|
||||||
|
// Tinted while it narrows the list.
|
||||||
|
color: state.filter == null ? null : theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final bloc = context.read<CardListBloc>();
|
||||||
|
final chosen = await showAdaptiveSheet<({String? tagId})>(
|
||||||
|
context,
|
||||||
|
builder: (context) => _FilterSheet(
|
||||||
|
tags: state.tagsInUse,
|
||||||
|
tagId: state.filter?.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (chosen != null) {
|
||||||
|
bloc.add(CardListEventTagFilterChanged(chosen.tagId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuAnchor(
|
||||||
|
menuChildren: [
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(LucideIcons.tags, size: 18),
|
||||||
|
onPressed: () => showManageTagsSheet(context),
|
||||||
|
child: Text(l10n.cardListManageTags),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, menu, _) => IconButton(
|
||||||
|
tooltip: l10n.cardListMore,
|
||||||
|
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||||
|
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses the tag to narrow the cards to, among [tags], or all of them;
|
||||||
|
/// completes with the choice once applied, or with null when closed.
|
||||||
|
class const _FilterSheet({
|
||||||
|
required final List<Tag> tags,
|
||||||
|
required final String? tagId,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_FilterSheet> createState() => _FilterSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterSheetState() extends State<_FilterSheet> {
|
||||||
|
late var _tagId = widget.tagId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SheetScaffold(
|
||||||
|
title: l10n.cardFilterTitle,
|
||||||
|
footer: Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _tagId == null
|
||||||
|
? null
|
||||||
|
: () => setState(() => _tagId = null),
|
||||||
|
child: Text(l10n.cardFilterClear),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop((tagId: _tagId)),
|
||||||
|
child: Text(l10n.cardFilterApply),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(l10n.cardFilterTag, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (widget.tags.isEmpty)
|
||||||
|
Text(
|
||||||
|
l10n.cardFilterNoTags,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(l10n.cardFilterAllTags),
|
||||||
|
selected: _tagId == null,
|
||||||
|
onSelected: (_) => setState(() => _tagId = null),
|
||||||
|
),
|
||||||
|
for (final tag in widget.tags)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(tag.name),
|
||||||
|
selected: tag.id == _tagId,
|
||||||
|
onSelected: (_) => setState(() => _tagId = tag.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No card has the tag the list is narrowed to.
|
||||||
|
class const _NothingTagged() extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).cardFilterNothing,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// No cards yet, and where they come from.
|
/// No cards yet, and where they come from.
|
||||||
class const _NoCards() extends StatelessWidget {
|
class const _NoCards() extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -65,11 +228,17 @@ class const _Cards(
|
||||||
final CardRepository repository,
|
final CardRepository repository,
|
||||||
final String studysetId,
|
final String studysetId,
|
||||||
final List<Card> cards,
|
final List<Card> cards,
|
||||||
) extends StatelessWidget {
|
final Tagging tagging, {
|
||||||
|
|
||||||
|
/// Whether cards can be dragged to another place: not while only some
|
||||||
|
/// show, as the places between them are unclear.
|
||||||
|
required final bool reorderable,
|
||||||
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ReorderableListView(
|
Widget build(BuildContext context) => ReorderableListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
buildDefaultDragHandles: reorderable,
|
||||||
onReorderItem: (from, to) => context.read<CardListBloc>().add(
|
onReorderItem: (from, to) => context.read<CardListBloc>().add(
|
||||||
CardListEventMoved(cards[from], toIndex: to),
|
CardListEventMoved(cards[from], toIndex: to),
|
||||||
),
|
),
|
||||||
|
|
@ -78,7 +247,10 @@ class const _Cards(
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey(card.id),
|
key: ValueKey(card.id),
|
||||||
title: Text(_preview(card.content.front.plainText)),
|
title: Text(_preview(card.content.front.plainText)),
|
||||||
subtitle: Text(_preview(card.content.back.plainText)),
|
subtitle: _Subtitle(
|
||||||
|
back: _preview(card.content.back.plainText),
|
||||||
|
tags: tagging.of(card.id),
|
||||||
|
),
|
||||||
leading: card.hasConflict
|
leading: card.hasConflict
|
||||||
? const Tooltip(
|
? const Tooltip(
|
||||||
message: 'Changed on two devices',
|
message: 'Changed on two devices',
|
||||||
|
|
@ -91,5 +263,33 @@ class const _Cards(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A card's back, and below it its tags, in one line that scrolls sideways
|
||||||
|
/// rather than growing.
|
||||||
|
class const _Subtitle({
|
||||||
|
required final String back,
|
||||||
|
required final List<Tag> tags,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (tags.isEmpty) {
|
||||||
|
return Text(back);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(back),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
spacing: 6,
|
||||||
|
children: [for (final tag in tags) TagLabel(tag: tag)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The first line of [text], or a dash for nothing.
|
/// The first line of [text], or a dash for nothing.
|
||||||
String _preview(String text) => text.isEmpty ? '–' : text.split('\n').first;
|
String _preview(String text) => text.isEmpty ? '–' : text.split('\n').first;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.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/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
// Material's Card widget would clash with the domain's Card.
|
// Material's Card widget would clash with the domain's Card.
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
||||||
|
|
@ -19,7 +23,9 @@ class const CardScreen({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) =>
|
||||||
BlocListener<CardEditBloc, CardEditState>(
|
BlocListener<CardEditBloc, CardEditState>(
|
||||||
listenWhen: (previous, current) => current.isDone,
|
// Once: the state goes on changing after (the tags it follows,
|
||||||
|
// say), and closing again would close the screen below.
|
||||||
|
listenWhen: (previous, current) => !previous.isDone && current.isDone,
|
||||||
listener: (context, state) => Navigator.of(context).pop(),
|
listener: (context, state) => Navigator.of(context).pop(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -85,6 +91,13 @@ class const CardScreen({
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).cardTagsLabel,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_Tags(state: state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -92,6 +105,43 @@ class const CardScreen({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tags chosen for the card, and choosing them.
|
||||||
|
class const _Tags({required final CardEditState state})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final tagIds = state.tagIds;
|
||||||
|
if (tagIds == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final chosen = [
|
||||||
|
for (final tag in state.tagging.all)
|
||||||
|
if (tagIds.contains(tag.id)) tag,
|
||||||
|
];
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (final tag in chosen) TagChip(tag: tag),
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(LucideIcons.pencil, size: 16),
|
||||||
|
label: Text(chosen.isEmpty ? l10n.cardTagsAdd : l10n.cardTagsEdit),
|
||||||
|
onPressed: () {
|
||||||
|
final bloc = context.read<CardEditBloc>();
|
||||||
|
showTagPicker(
|
||||||
|
context,
|
||||||
|
selected: tagIds,
|
||||||
|
onChanged: (tagIds) => bloc.add(CardEditEventTagsChanged(tagIds)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One version of a side devices changed differently, and keeping it.
|
/// One version of a side devices changed differently, and keeping it.
|
||||||
class const _Version({
|
class const _Version({
|
||||||
required final CardSide side,
|
required final CardSide side,
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ class const _Content({required final String studysetId})
|
||||||
children: [
|
children: [
|
||||||
CardList(
|
CardList(
|
||||||
cards: PersonScope.of(context).cards,
|
cards: PersonScope.of(context).cards,
|
||||||
|
tags: PersonScope.of(context).tags,
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
109
halkyon_learn_flutter/lib/src/feature/tag/bloc/tags_bloc.dart
Normal file
109
halkyon_learn_flutter/lib/src/feature/tag/bloc/tags_bloc.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
|
|
||||||
|
/// Shows the person's tags and creates, changes and deletes them; the
|
||||||
|
/// tagging follows every change, local or synced.
|
||||||
|
class TagsBloc(final TagRepository _tags) extends Bloc<TagsEvent, TagsState> {
|
||||||
|
this : super(const TagsStateLoading()) {
|
||||||
|
on<TagsEventStarted>(
|
||||||
|
(event, emit) => emit.forEach(
|
||||||
|
_tags.watch(),
|
||||||
|
onData: TagsStateLoaded.new,
|
||||||
|
onError: (_, _) => const TagsStateFailed(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<TagsEventCreateRequested>(
|
||||||
|
(event, emit) => _tags.create(
|
||||||
|
event.content.name,
|
||||||
|
colorName: event.content.colorName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<TagsEventUpdateRequested>(
|
||||||
|
(event, emit) => _tags.update(
|
||||||
|
event.id,
|
||||||
|
name: event.content.name,
|
||||||
|
colorName: event.content.colorName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<TagsEventDeleteRequested>((event, emit) async {
|
||||||
|
_deleting = _tags.delete(event.id);
|
||||||
|
await _deleting;
|
||||||
|
});
|
||||||
|
on<TagsEventUndoDeleteRequested>((event, emit) async {
|
||||||
|
final deleting = _deleting;
|
||||||
|
_deleting = null;
|
||||||
|
if (deleting != null) {
|
||||||
|
await _tags.restore(await deleting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last deletion, which an undo takes back.
|
||||||
|
Future<DeletedTag>? _deleting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to the person's tags.
|
||||||
|
sealed class const TagsEvent() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow the person's tags.
|
||||||
|
final class const TagsEventStarted() extends TagsEvent;
|
||||||
|
|
||||||
|
/// Create a tag with [content]; a name that exists already is that tag.
|
||||||
|
final class const TagsEventCreateRequested(
|
||||||
|
/// The new tag's name and colour.
|
||||||
|
final TagContent content,
|
||||||
|
) extends TagsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [content];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename and recolour the tag [id]; a name another tag has merges the two.
|
||||||
|
final class const TagsEventUpdateRequested(
|
||||||
|
/// The tag's id.
|
||||||
|
final String id,
|
||||||
|
|
||||||
|
/// Its new name and colour.
|
||||||
|
final TagContent content,
|
||||||
|
) extends TagsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, content];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the tag [id] from every card and then itself.
|
||||||
|
final class const TagsEventDeleteRequested(
|
||||||
|
/// The tag's id.
|
||||||
|
final String id,
|
||||||
|
) extends TagsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the last deletion: a tag of its name and colour, on its cards.
|
||||||
|
final class const TagsEventUndoDeleteRequested() extends TagsEvent;
|
||||||
|
|
||||||
|
/// The person's tags, as the sheets show them.
|
||||||
|
sealed class const TagsState() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading the tags.
|
||||||
|
final class const TagsStateLoading() extends TagsState;
|
||||||
|
|
||||||
|
/// The tags couldn't be read; the next change tries again.
|
||||||
|
final class const TagsStateFailed() extends TagsState;
|
||||||
|
|
||||||
|
/// The tags and which cards have them.
|
||||||
|
final class const TagsStateLoaded(
|
||||||
|
/// The person's tagging.
|
||||||
|
final Tagging tagging,
|
||||||
|
) extends TagsState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [tagging];
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ typedef DeletedTag = ({String name, String colorName, Set<String> cardIds});
|
||||||
/// the same one, so they all end up alike.
|
/// the same one, so they all end up alike.
|
||||||
class TagRepository(
|
class TagRepository(
|
||||||
final TagGateway _gateway, {
|
final TagGateway _gateway, {
|
||||||
|
|
||||||
/// Fires when cards change; a deleted card takes its tags along.
|
/// Fires when cards change; a deleted card takes its tags along.
|
||||||
Stream<void> cardChanges = const Stream.empty(),
|
Stream<void> cardChanges = const Stream.empty(),
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -33,8 +34,11 @@ class TagRepository(
|
||||||
late final List<StreamSubscription<void>> _subscriptions;
|
late final List<StreamSubscription<void>> _subscriptions;
|
||||||
|
|
||||||
// Every read and write waits for the one before, so a fold never meets a
|
// Every read and write waits for the one before, so a fold never meets a
|
||||||
// half-done change, and an older tagging never overtakes a newer one.
|
// half-done change, and an older tagging never overtakes a newer one. Null
|
||||||
Future<void> _queue = Future.value();
|
// while nothing runs: a completed future kept from before would run what
|
||||||
|
// waits for it in the zone it was made in, which a widget test's fake
|
||||||
|
// clock never reaches.
|
||||||
|
Future<void>? _queue;
|
||||||
|
|
||||||
/// The tagging now and after every change, local or synced. A read that
|
/// The tagging now and after every change, local or synced. A read that
|
||||||
/// fails arrives as an error; the next change reads again.
|
/// fails arrives as an error; the next change reads again.
|
||||||
|
|
@ -60,14 +64,7 @@ class TagRepository(
|
||||||
if (canonicalTagName(name).isEmpty) {
|
if (canonicalTagName(name).isEmpty) {
|
||||||
throw ArgumentError.value(name, 'name', 'is blank');
|
throw ArgumentError.value(name, 'name', 'is blank');
|
||||||
}
|
}
|
||||||
final id = await _serially(() async {
|
final id = await _serially(() => _idOf(name, colorName));
|
||||||
if ((await _read()).named(name) case final existing?) {
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
return await _gateway.insertTag(
|
|
||||||
encodeTagPayload((name: name, colorName: colorName)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await _refresh();
|
await _refresh();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
@ -142,8 +139,8 @@ class TagRepository(
|
||||||
if (deleted.name.isEmpty) {
|
if (deleted.name.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final id = await create(deleted.name, colorName: deleted.colorName);
|
|
||||||
await _serially(() async {
|
await _serially(() async {
|
||||||
|
final id = await _idOf(deleted.name, deleted.colorName);
|
||||||
final has = (await _read()).cardsWith(id);
|
final has = (await _read()).cardsWith(id);
|
||||||
await _gateway.insertCardTags([
|
await _gateway.insertCardTags([
|
||||||
for (final cardId in deleted.cardIds)
|
for (final cardId in deleted.cardIds)
|
||||||
|
|
@ -162,10 +159,19 @@ class TagRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs [action] once everything before it is done.
|
/// Runs [action] once everything before it is done.
|
||||||
Future<T> _serially<T>(Future<T> Function() action) {
|
Future<T> _serially<T>(Future<T> Function() action) async {
|
||||||
final result = _queue.then((_) => action());
|
final previous = _queue;
|
||||||
_queue = result.then<void>((_) {}, onError: (_) {});
|
final done = Completer<void>();
|
||||||
return result;
|
_queue = done.future;
|
||||||
|
try {
|
||||||
|
await previous;
|
||||||
|
return await action();
|
||||||
|
} finally {
|
||||||
|
done.complete();
|
||||||
|
if (identical(_queue, done.future)) {
|
||||||
|
_queue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failed read goes to the listeners and never ends the queue: the next
|
// A failed read goes to the listeners and never ends the queue: the next
|
||||||
|
|
@ -243,6 +249,17 @@ class TagRepository(
|
||||||
return folded;
|
return folded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The id of the tag named [name]: an existing one, or a new one in
|
||||||
|
/// [colorName]; call it only in [_serially].
|
||||||
|
Future<String> _idOf(String name, String colorName) async {
|
||||||
|
if ((await _read()).named(name) case final existing?) {
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
return await _gateway.insertTag(
|
||||||
|
encodeTagPayload((name: name, colorName: colorName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Blanks the tag [id], then deletes it from every card and itself.
|
/// Blanks the tag [id], then deletes it from every card and itself.
|
||||||
Future<void> _deleteTag(String id) async {
|
Future<void> _deleteTag(String id) async {
|
||||||
await _gateway.updateTagPayload(id, redactedPayload);
|
await _gateway.updateTagPayload(id, redactedPayload);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
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/core/widget/confirm_dialog.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.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/model/tagging.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_sheet.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Shows every tag with how many flashcards have it, to add, rename,
|
||||||
|
/// recolour and delete tags; a deletion can be undone for a few seconds.
|
||||||
|
Future<void> showManageTagsSheet(BuildContext context) =>
|
||||||
|
showAdaptiveSheet<void>(
|
||||||
|
context,
|
||||||
|
builder: (context) => BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
TagsBloc(PersonScope.of(context).tags)
|
||||||
|
..add(const TagsEventStarted()),
|
||||||
|
child: const _ManageTags(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _ManageTags() extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return BlocBuilder<TagsBloc, TagsState>(
|
||||||
|
builder: (context, state) => SheetScaffold(
|
||||||
|
title: l10n.tagManageTitle,
|
||||||
|
heightFactor: 0.8,
|
||||||
|
// The Undo of a deletion shows on the sheet, where it can be tapped.
|
||||||
|
hasSnackBars: true,
|
||||||
|
footer: switch (state) {
|
||||||
|
TagsStateLoaded(:final tagging) => Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => _add(context, tagging),
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: Text(l10n.tagManageAdd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
body: switch (state) {
|
||||||
|
TagsStateLoading() => const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(child: AppLoadingIndicator()),
|
||||||
|
),
|
||||||
|
TagsStateFailed() => Text(l10n.tagsReadFailed),
|
||||||
|
TagsStateLoaded(tagging: Tagging(all: [])) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||||
|
child: Text(
|
||||||
|
l10n.tagManageEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TagsStateLoaded(:final tagging) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final tag in tagging.all)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: _TagRow(tag: tag, tagging: tagging),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _add(BuildContext context, Tagging tagging) async {
|
||||||
|
final bloc = context.read<TagsBloc>();
|
||||||
|
if (await showTagSheet(context, tagging: tagging) case final content?) {
|
||||||
|
bloc.add(TagsEventCreateRequested(content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tag with its count of flashcards, and editing and deleting it.
|
||||||
|
class const _TagRow({required final Tag tag, required final Tagging tagging})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = tagColor(tag);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n.tagManageRow(tag.name, tagging.cardsWith(tag.id).length),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: color.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.tagManageEdit,
|
||||||
|
onPressed: () => _edit(context),
|
||||||
|
icon: const Icon(LucideIcons.pencil, size: 18),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.tagManageDelete,
|
||||||
|
onPressed: () => _delete(context),
|
||||||
|
icon: Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _edit(BuildContext context) async {
|
||||||
|
final bloc = context.read<TagsBloc>();
|
||||||
|
if (await showTagSheet(context, tagging: tagging, initial: tag)
|
||||||
|
case final content?) {
|
||||||
|
bloc.add(TagsEventUpdateRequested(tag.id, content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(BuildContext context) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final bloc = context.read<TagsBloc>();
|
||||||
|
if (!await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.tagDeleteTitle(tag.name),
|
||||||
|
message: l10n.tagDeleteMessage(tagging.cardsWith(tag.id).length),
|
||||||
|
confirmLabel: l10n.tagDeleteConfirm,
|
||||||
|
) ||
|
||||||
|
!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bloc.add(TagsEventDeleteRequested(tag.id));
|
||||||
|
showUndoSnackBar(
|
||||||
|
context,
|
||||||
|
message: l10n.tagDeleted(tag.name),
|
||||||
|
onUndo: () => bloc.add(const TagsEventUndoDeleteRequested()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_chip.dart
Normal file
102
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_chip.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A tag as a chip, filled with its colour and labelled in the colour's
|
||||||
|
/// foreground: the theme's text colours are unreadable on half the palette.
|
||||||
|
/// Without [onPressed] it only shows the tag.
|
||||||
|
class const TagChip({
|
||||||
|
/// The tag shown.
|
||||||
|
required final Tag tag,
|
||||||
|
|
||||||
|
/// What tapping the chip does.
|
||||||
|
final VoidCallback? onPressed,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = tagColor(tag);
|
||||||
|
if (onPressed case final onPressed?) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(tag.name),
|
||||||
|
backgroundColor: color.background,
|
||||||
|
side: BorderSide.none,
|
||||||
|
labelStyle: TextStyle(color: color.foreground),
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Chip(
|
||||||
|
label: Text(tag.name),
|
||||||
|
backgroundColor: color.background,
|
||||||
|
side: BorderSide.none,
|
||||||
|
labelStyle: TextStyle(color: color.foreground),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chip toggling a tag: filled with its colour while selected, and
|
||||||
|
/// outlined in it otherwise, so a long list still reads as these tags.
|
||||||
|
class const SelectableTagChip({
|
||||||
|
/// The tag toggled.
|
||||||
|
required final Tag tag,
|
||||||
|
|
||||||
|
/// Whether it is selected.
|
||||||
|
required final bool selected,
|
||||||
|
|
||||||
|
/// Called when it is toggled; null disables it.
|
||||||
|
required final ValueChanged<bool>? onSelected,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = tagColor(tag);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(tag.name),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: onSelected,
|
||||||
|
// The fill says it is selected; a check mark as well would crowd
|
||||||
|
// short names.
|
||||||
|
showCheckmark: false,
|
||||||
|
selectedColor: color.background,
|
||||||
|
side: selected ? null : BorderSide(color: color.background),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected
|
||||||
|
? color.foreground
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tag as a small label, for lists where a chip would be too big.
|
||||||
|
class const TagLabel({required final Tag tag, super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = tagColor(tag);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.background,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
|
child: Text(
|
||||||
|
tag.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: color.foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [tag]'s colour; the default one for a colour only a newer app version
|
||||||
|
/// knows.
|
||||||
|
PaletteColor tagColor(Tag tag) =>
|
||||||
|
PaletteColor.byName(tag.colorName) ??
|
||||||
|
PaletteColor.byName(defaultTagColorName)!;
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
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/tag/bloc/tags_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.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:halkyon_learn_flutter/src/feature/tag/widget/tag_sheet.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Lets the person choose tags, those with the ids in [selected] chosen at
|
||||||
|
/// first. [onChanged] gets the choice after every tap, so however the sheet
|
||||||
|
/// closes, nothing is lost.
|
||||||
|
///
|
||||||
|
/// Typing narrows the tags to those whose name starts with it; a name no tag
|
||||||
|
/// has offers to create one, which is then chosen. Managing the tags
|
||||||
|
/// (renaming, recolouring, deleting) is a tap away in the header.
|
||||||
|
Future<void> showTagPicker(
|
||||||
|
BuildContext context, {
|
||||||
|
required Set<String> selected,
|
||||||
|
required ValueChanged<Set<String>> onChanged,
|
||||||
|
}) => showAdaptiveSheet<void>(
|
||||||
|
context,
|
||||||
|
builder: (context) => BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
TagsBloc(PersonScope.of(context).tags)..add(const TagsEventStarted()),
|
||||||
|
child: _TagPicker(selected: selected, onChanged: onChanged),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _TagPicker({
|
||||||
|
required final Set<String> selected,
|
||||||
|
required final ValueChanged<Set<String>> onChanged,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_TagPicker> createState() => _TagPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TagPickerState() extends State<_TagPicker> {
|
||||||
|
late var _selected = {...widget.selected};
|
||||||
|
final _query = TextEditingController();
|
||||||
|
|
||||||
|
/// Names of tags created here that aren't listed yet; each is chosen as
|
||||||
|
/// it appears.
|
||||||
|
final _created = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_query.addListener(() => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_query.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle(String id, {required bool selected}) {
|
||||||
|
setState(
|
||||||
|
() => _selected = selected
|
||||||
|
? {..._selected, id}
|
||||||
|
: ({..._selected}..remove(id)),
|
||||||
|
);
|
||||||
|
widget.onChanged(_selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a tag named [name], with a colour the person picks, and
|
||||||
|
/// chooses it.
|
||||||
|
Future<void> _create(Tagging tagging, String name) async {
|
||||||
|
final bloc = context.read<TagsBloc>();
|
||||||
|
final content = await showTagSheet(
|
||||||
|
context,
|
||||||
|
tagging: tagging,
|
||||||
|
initialName: canonicalTagName(name),
|
||||||
|
);
|
||||||
|
if (content == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_created.add(canonicalTagName(content.name));
|
||||||
|
bloc.add(TagsEventCreateRequested(content));
|
||||||
|
_query.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses every tag created here that is now listed.
|
||||||
|
void _chooseCreated(Tagging tagging) {
|
||||||
|
final appeared = [for (final name in _created) ?tagging.named(name)];
|
||||||
|
if (appeared.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final tag in appeared) {
|
||||||
|
_created.remove(tag.name);
|
||||||
|
}
|
||||||
|
setState(
|
||||||
|
() => _selected = {..._selected, for (final tag in appeared) tag.id},
|
||||||
|
);
|
||||||
|
widget.onChanged(_selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return BlocConsumer<TagsBloc, TagsState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state case TagsStateLoaded(:final tagging)) {
|
||||||
|
_chooseCreated(tagging);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) => SheetScaffold(
|
||||||
|
title: l10n.tagPickerTitle,
|
||||||
|
action: TextButton.icon(
|
||||||
|
onPressed: () => showManageTagsSheet(context),
|
||||||
|
icon: const Icon(LucideIcons.settings2, size: 16),
|
||||||
|
label: Text(l10n.tagPickerManage),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...switch (state) {
|
||||||
|
TagsStateLoading() => [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(child: AppLoadingIndicator()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
TagsStateFailed() => [Text(l10n.tagsReadFailed)],
|
||||||
|
TagsStateLoaded(:final tagging) => _choices(context, tagging),
|
||||||
|
},
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _choices(BuildContext context, Tagging tagging) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final query = canonicalTagName(_query.text);
|
||||||
|
final shown = [
|
||||||
|
for (final tag in tagging.all)
|
||||||
|
if (tag.name.startsWith(query)) tag,
|
||||||
|
];
|
||||||
|
final canCreate = query.isNotEmpty && tagging.named(query) == null;
|
||||||
|
return [
|
||||||
|
TextField(
|
||||||
|
// No keyboard popping up as the sheet opens: the person taps the
|
||||||
|
// field when ready.
|
||||||
|
controller: _query,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.tagPickerSearchLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, size: 18),
|
||||||
|
),
|
||||||
|
onSubmitted: (text) => canCreate ? _create(tagging, text) : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final tag in shown)
|
||||||
|
SelectableTagChip(
|
||||||
|
tag: tag,
|
||||||
|
selected: _selected.contains(tag.id),
|
||||||
|
onSelected: (selected) => _toggle(tag.id, selected: selected),
|
||||||
|
),
|
||||||
|
if (canCreate)
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(LucideIcons.plus, size: 16),
|
||||||
|
label: Text(l10n.tagPickerCreate(query)),
|
||||||
|
onPressed: () => _create(tagging, query),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
116
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_sheet.dart
Normal file
116
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_sheet.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Creates a tag's content, starting from [initialName], or edits
|
||||||
|
/// [initial]'s: its name and colour. Completes with the content once the
|
||||||
|
/// person saves, or with null when they close the sheet.
|
||||||
|
///
|
||||||
|
/// Names compare as tags do, against [tagging]: a new tag can't take a name
|
||||||
|
/// that exists, and an edited one taking another's name says that saving
|
||||||
|
/// merges the two.
|
||||||
|
Future<TagContent?> showTagSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required Tagging tagging,
|
||||||
|
Tag? initial,
|
||||||
|
String initialName = '',
|
||||||
|
}) => showAdaptiveSheet<TagContent>(
|
||||||
|
context,
|
||||||
|
builder: (context) =>
|
||||||
|
_TagSheet(tagging: tagging, initial: initial, initialName: initialName),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _TagSheet({
|
||||||
|
required final Tagging tagging,
|
||||||
|
required final Tag? initial,
|
||||||
|
required final String initialName,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_TagSheet> createState() => _TagSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TagSheetState() extends State<_TagSheet> {
|
||||||
|
late final _name = TextEditingController(
|
||||||
|
text: widget.initial?.name ?? widget.initialName,
|
||||||
|
);
|
||||||
|
late var _color = switch (widget.initial) {
|
||||||
|
final initial? => tagColor(initial),
|
||||||
|
null => PaletteColor.byName(defaultTagColorName)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// The button and the hint below the name follow it.
|
||||||
|
_name.addListener(() => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tag that has the typed name already, other than the one edited.
|
||||||
|
Tag? get _namesake => switch (widget.tagging.named(_name.text)) {
|
||||||
|
final namesake? when namesake.id != widget.initial?.id => namesake,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final creating = widget.initial == null;
|
||||||
|
final namesake = _namesake;
|
||||||
|
final blank = canonicalTagName(_name.text).isEmpty;
|
||||||
|
return SheetScaffold(
|
||||||
|
title: creating ? l10n.tagSheetCreateTitle : l10n.tagSheetEditTitle,
|
||||||
|
footer: FilledButton(
|
||||||
|
onPressed: blank || (creating && namesake != null)
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop((name: _name.text, colorName: _color.name)),
|
||||||
|
child: Text(creating ? l10n.tagSheetCreate : l10n.tagSheetSave),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
// No keyboard popping up as the sheet opens: the person taps the
|
||||||
|
// field when ready. Names are lowercase, so the keyboard offers no
|
||||||
|
// capitals either.
|
||||||
|
controller: _name,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.tagSheetNameLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
helperText: namesake != null && !creating
|
||||||
|
? l10n.tagSheetNameMerges(namesake.name)
|
||||||
|
: l10n.tagSheetNameHelper,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
errorText: namesake != null && creating
|
||||||
|
? l10n.tagSheetNameExists(namesake.name)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(l10n.tagSheetColor, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
PaletteColorPicker(
|
||||||
|
selected: _color,
|
||||||
|
onChanged: (color) => setState(() => _color = color),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,10 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
|
||||||
import '../../../support/fake_card_gateway.dart';
|
import '../../../support/fake_card_gateway.dart';
|
||||||
|
import '../../../support/fake_tag_gateway.dart';
|
||||||
|
|
||||||
RichContent text(String text) => RichContent({
|
RichContent text(String text) => RichContent({
|
||||||
'document': {'type': 'page', 'note': text},
|
'document': {'type': 'page', 'note': text},
|
||||||
|
|
@ -19,17 +21,22 @@ CardContent card(String front, String back) =>
|
||||||
void main() {
|
void main() {
|
||||||
late FakeCardGateway gateway;
|
late FakeCardGateway gateway;
|
||||||
late CardRepository cards;
|
late CardRepository cards;
|
||||||
|
late TagRepository tags;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
gateway = FakeCardGateway();
|
gateway = FakeCardGateway();
|
||||||
cards = CardRepository(gateway);
|
cards = CardRepository(gateway);
|
||||||
|
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await tags.dispose();
|
||||||
|
await cards.dispose();
|
||||||
});
|
});
|
||||||
tearDown(() => cards.dispose());
|
|
||||||
|
|
||||||
group('CardListBloc', () {
|
group('CardListBloc', () {
|
||||||
test('lists the cards and follows every change', () async {
|
test('lists the cards and follows every change', () async {
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
await cards.create('japanese', card('猫', 'cat'));
|
||||||
final bloc = CardListBloc(cards, 'japanese')
|
final bloc = CardListBloc(cards, tags, 'japanese')
|
||||||
..add(const CardListEventStarted());
|
..add(const CardListEventStarted());
|
||||||
addTearDown(bloc.close);
|
addTearDown(bloc.close);
|
||||||
|
|
||||||
|
|
@ -59,7 +66,7 @@ void main() {
|
||||||
test('moves a card', () async {
|
test('moves a card', () async {
|
||||||
await cards.create('japanese', card('a', ''));
|
await cards.create('japanese', card('a', ''));
|
||||||
await cards.create('japanese', card('b', ''));
|
await cards.create('japanese', card('b', ''));
|
||||||
final bloc = CardListBloc(cards, 'japanese')
|
final bloc = CardListBloc(cards, tags, 'japanese')
|
||||||
..add(const CardListEventStarted());
|
..add(const CardListEventStarted());
|
||||||
addTearDown(bloc.close);
|
addTearDown(bloc.close);
|
||||||
final loaded = await bloc.stream.firstWhere(
|
final loaded = await bloc.stream.firstWhere(
|
||||||
|
|
@ -81,10 +88,74 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CardListBloc with tags', () {
|
||||||
|
late CardListBloc bloc;
|
||||||
|
late List<String> ids;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
for (final front in ['猫', '犬', '食べる']) {
|
||||||
|
await cards.create('japanese', card(front, ''));
|
||||||
|
}
|
||||||
|
ids = [for (final card in await cards.cardsOf('japanese')) card.id];
|
||||||
|
final noun = await tags.create('noun');
|
||||||
|
final verb = await tags.create('verb');
|
||||||
|
await tags.create('unused');
|
||||||
|
await tags.setTagsOf(ids[0], {noun});
|
||||||
|
await tags.setTagsOf(ids[1], {noun});
|
||||||
|
await tags.setTagsOf(ids[2], {verb});
|
||||||
|
bloc = CardListBloc(cards, tags, 'japanese')
|
||||||
|
..add(const CardListEventStarted());
|
||||||
|
});
|
||||||
|
tearDown(() => bloc.close());
|
||||||
|
|
||||||
|
/// The next state that has the tags read.
|
||||||
|
Future<CardListStateLoaded> tagged() async => await bloc.stream.firstWhere(
|
||||||
|
(state) => state is CardListStateLoaded && state.tagging.all.isNotEmpty,
|
||||||
|
) as CardListStateLoaded;
|
||||||
|
|
||||||
|
test("shows each card's tags, and offers to filter by those the cards "
|
||||||
|
'have', () async {
|
||||||
|
final state = await tagged();
|
||||||
|
|
||||||
|
expect(state.tagging.of(ids[2]).map((tag) => tag.name), ['verb']);
|
||||||
|
expect(state.tagsInUse.map((tag) => tag.name), ['noun', 'verb']);
|
||||||
|
expect(state.shown, hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tag narrows the list to its cards, and clearing it shows them '
|
||||||
|
'all again', () async {
|
||||||
|
final noun = (await tagged()).tagging.named('noun')!;
|
||||||
|
|
||||||
|
bloc.add(CardListEventTagFilterChanged(noun.id));
|
||||||
|
final narrowed = await bloc.stream.first as CardListStateLoaded;
|
||||||
|
bloc.add(const CardListEventTagFilterChanged(null));
|
||||||
|
final all = await bloc.stream.first as CardListStateLoaded;
|
||||||
|
|
||||||
|
expect(narrowed.filter, noun);
|
||||||
|
expect(narrowed.shown.map((card) => card.id), [ids[0], ids[1]]);
|
||||||
|
expect(all.shown, hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deleted tag no longer narrows the list', () async {
|
||||||
|
final noun = (await tagged()).tagging.named('noun')!;
|
||||||
|
bloc.add(CardListEventTagFilterChanged(noun.id));
|
||||||
|
await bloc.stream.first;
|
||||||
|
|
||||||
|
await tags.delete(noun.id);
|
||||||
|
final state = await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is CardListStateLoaded && state.tagging.named('noun') == null,
|
||||||
|
) as CardListStateLoaded;
|
||||||
|
|
||||||
|
expect(state.filter, isNull);
|
||||||
|
expect(state.shown, hasLength(3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('CardEditBloc', () {
|
group('CardEditBloc', () {
|
||||||
test('a new card can be saved once it has content, and then is '
|
test('a new card can be saved once it has content, and then is '
|
||||||
'done', () async {
|
'done', () async {
|
||||||
final bloc = CardEditBloc(cards, 'japanese', null);
|
final bloc = CardEditBloc(cards, tags, 'japanese', null);
|
||||||
addTearDown(bloc.close);
|
addTearDown(bloc.close);
|
||||||
expect(bloc.state.canSave, isFalse);
|
expect(bloc.state.canSave, isFalse);
|
||||||
|
|
||||||
|
|
@ -109,6 +180,7 @@ void main() {
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
await cards.create('japanese', card('猫', 'cat'));
|
||||||
final bloc = CardEditBloc(
|
final bloc = CardEditBloc(
|
||||||
cards,
|
cards,
|
||||||
|
tags,
|
||||||
'japanese',
|
'japanese',
|
||||||
(await cards.cardsOf('japanese')).single,
|
(await cards.cardsOf('japanese')).single,
|
||||||
);
|
);
|
||||||
|
|
@ -138,7 +210,7 @@ void main() {
|
||||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||||
);
|
);
|
||||||
conflicted = (await cards.cardsOf('japanese')).single;
|
conflicted = (await cards.cardsOf('japanese')).single;
|
||||||
bloc = CardEditBloc(cards, 'japanese', conflicted);
|
bloc = CardEditBloc(cards, tags, 'japanese', conflicted);
|
||||||
});
|
});
|
||||||
tearDown(() => bloc.close());
|
tearDown(() => bloc.close());
|
||||||
|
|
||||||
|
|
@ -171,10 +243,71 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows a card's tags; changing only them can be saved, without a "
|
||||||
|
'new revision of its sides', () async {
|
||||||
|
final cardId = await cards.create('japanese', card('猫', 'cat'));
|
||||||
|
final noun = await tags.create('noun');
|
||||||
|
final animal = await tags.create('animal');
|
||||||
|
await tags.setTagsOf(cardId, {noun});
|
||||||
|
final bloc = CardEditBloc(
|
||||||
|
cards,
|
||||||
|
tags,
|
||||||
|
'japanese',
|
||||||
|
(await cards.cardsOf('japanese')).single,
|
||||||
|
)..add(const CardEditEventStarted());
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
final loaded = await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||||
|
expect(loaded.tagIds, {noun});
|
||||||
|
expect(loaded.canSave, isFalse);
|
||||||
|
|
||||||
|
bloc.add(CardEditEventTagsChanged({noun, animal}));
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
||||||
|
);
|
||||||
|
bloc.add(const CardEditEventSaveRequested());
|
||||||
|
await bloc.stream.firstWhere((s) => s.isDone);
|
||||||
|
|
||||||
|
final tagging = await tags.read();
|
||||||
|
expect(tagging.of(cardId).map((tag) => tag.name), ['animal', 'noun']);
|
||||||
|
expect(await gateway.revisionsOf({cardId}), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new card gets the tags chosen once saved', () async {
|
||||||
|
final noun = await tags.create('noun');
|
||||||
|
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
||||||
|
..add(const CardEditEventStarted());
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||||
|
|
||||||
|
bloc
|
||||||
|
..add(CardEditEventTagsChanged({noun}))
|
||||||
|
..add(CardEditEventSideChanged(CardSide.front, text('猫')))
|
||||||
|
..add(const CardEditEventSaveRequested());
|
||||||
|
await bloc.stream.firstWhere((s) => s.isDone);
|
||||||
|
|
||||||
|
final cardId = (await cards.cardsOf('japanese')).single.id;
|
||||||
|
expect((await tags.read()).of(cardId).map((tag) => tag.id), [noun]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new card with only tags has nothing to save', () async {
|
||||||
|
final noun = await tags.create('noun');
|
||||||
|
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
||||||
|
..add(const CardEditEventStarted());
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||||
|
|
||||||
|
bloc.add(CardEditEventTagsChanged({noun}));
|
||||||
|
final state = await bloc.stream.first;
|
||||||
|
|
||||||
|
expect(state.canSave, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('deleting the card is done too', () async {
|
test('deleting the card is done too', () async {
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
await cards.create('japanese', card('猫', 'cat'));
|
||||||
final bloc = CardEditBloc(
|
final bloc = CardEditBloc(
|
||||||
cards,
|
cards,
|
||||||
|
tags,
|
||||||
'japanese',
|
'japanese',
|
||||||
(await cards.cardsOf('japanese')).single,
|
(await cards.cardsOf('japanese')).single,
|
||||||
)..add(const CardEditEventDeleteRequested());
|
)..add(const CardEditEventDeleteRequested());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.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 '../../../support/fake_tag_gateway.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeTagGateway gateway;
|
||||||
|
late TagRepository tags;
|
||||||
|
late TagsBloc bloc;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
gateway = FakeTagGateway();
|
||||||
|
tags = TagRepository(gateway);
|
||||||
|
bloc = TagsBloc(tags)..add(const TagsEventStarted());
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await bloc.close();
|
||||||
|
await tags.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The next state whose tags are [expected], as (name, colour).
|
||||||
|
Future<TagsStateLoaded> showing(List<(String, String)> expected) async {
|
||||||
|
bool matches(TagsState state) =>
|
||||||
|
state is TagsStateLoaded &&
|
||||||
|
state.tagging.all.map((tag) => (tag.name, tag.colorName)).join('|') ==
|
||||||
|
expected.join('|');
|
||||||
|
if (matches(bloc.state)) {
|
||||||
|
return bloc.state as TagsStateLoaded;
|
||||||
|
}
|
||||||
|
return await bloc.stream.firstWhere(matches) as TagsStateLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates, renames and recolours tags', () async {
|
||||||
|
await showing([]);
|
||||||
|
|
||||||
|
bloc.add(
|
||||||
|
const TagsEventCreateRequested((name: 'Verb', colorName: 'fern')),
|
||||||
|
);
|
||||||
|
final created = await showing([('verb', 'fern')]);
|
||||||
|
bloc.add(
|
||||||
|
TagsEventUpdateRequested(
|
||||||
|
created.tagging.all.single.id,
|
||||||
|
(name: 'verbs', colorName: 'plum'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await showing([('verbs', 'plum')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a tag, and an undo brings it back on its cards', () async {
|
||||||
|
final verb = await tags.create('verb', colorName: 'fern');
|
||||||
|
await tags.setTagsOf('card-1', {verb});
|
||||||
|
await showing([('verb', 'fern')]);
|
||||||
|
|
||||||
|
bloc.add(TagsEventDeleteRequested(verb));
|
||||||
|
await showing([]);
|
||||||
|
bloc.add(const TagsEventUndoDeleteRequested());
|
||||||
|
|
||||||
|
final restored = await showing([('verb', 'fern')]);
|
||||||
|
expect(restored.tagging.of('card-1').map((tag) => tag.name), ['verb']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an undo right after deleting waits for the deletion', () async {
|
||||||
|
final verb = await tags.create('verb');
|
||||||
|
await showing([('verb', defaultTagColorName)]);
|
||||||
|
|
||||||
|
bloc
|
||||||
|
..add(TagsEventDeleteRequested(verb))
|
||||||
|
..add(const TagsEventUndoDeleteRequested());
|
||||||
|
|
||||||
|
await showing([]);
|
||||||
|
await showing([('verb', defaultTagColorName)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed read shows as failed', () async {
|
||||||
|
await bloc.close();
|
||||||
|
gateway.failReads = Exception('disk');
|
||||||
|
bloc = TagsBloc(tags)..add(const TagsEventStarted());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(const TagsStateFailed()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
||||||
|
import '../../../support/fake_card_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
import '../../../support/fake_tag_gateway.dart';
|
||||||
|
import '../../../support/pump_app.dart';
|
||||||
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
/// Rich content with one paragraph of [text].
|
||||||
|
RichContent paragraph(String text) => RichContent({
|
||||||
|
'document': {
|
||||||
|
'type': 'page',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'type': 'paragraph',
|
||||||
|
'data': {
|
||||||
|
'delta': [
|
||||||
|
{'insert': text},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeCardGateway cardGateway;
|
||||||
|
late FakeTagGateway tagGateway;
|
||||||
|
late CardRepository cards;
|
||||||
|
late TagRepository tags;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
cardGateway = FakeCardGateway();
|
||||||
|
tagGateway = FakeTagGateway();
|
||||||
|
cards = CardRepository(cardGateway);
|
||||||
|
tags = TagRepository(tagGateway);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await tags.dispose();
|
||||||
|
await cards.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A card in the studyset `japanese` with [front], and the tags [named].
|
||||||
|
Future<String> tagged(String front, [List<String> named = const []]) async {
|
||||||
|
final id = await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(front: paragraph(front), back: paragraph('')),
|
||||||
|
);
|
||||||
|
await tags.setTagsOf(id, {
|
||||||
|
for (final name in named) await tags.create(name),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the studyset `japanese`, with the cards and tags the test stored
|
||||||
|
/// through [cards] and [tags]: the app reads them from the same gateways.
|
||||||
|
Future<void> showList(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final studysets = FakeStudysetGateway()
|
||||||
|
..receiveRemoteChange(
|
||||||
|
'japanese',
|
||||||
|
encodeStudysetPayload(
|
||||||
|
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
testDependencies(
|
||||||
|
studysetGateway: studysets,
|
||||||
|
cardGateway: cardGateway,
|
||||||
|
tagGateway: tagGateway,
|
||||||
|
),
|
||||||
|
location: RoutePaths.studyset('japanese'),
|
||||||
|
);
|
||||||
|
// Flushes the description view's timers.
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the card with [front] in its editor.
|
||||||
|
Future<void> openCard(WidgetTester tester, String front) async {
|
||||||
|
await tester.tap(find.text(front));
|
||||||
|
await tester.pump();
|
||||||
|
// Flushes the editor's timers.
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the tags sheet from the card list's menu.
|
||||||
|
Future<void> manageTags(WidgetTester tester) async {
|
||||||
|
await tester.tap(find.byTooltip('More'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Manage tags'));
|
||||||
|
// A menu item acts once the menu has closed, a frame later.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder chip<T extends Widget>(String label) => find.widgetWithText(T, label);
|
||||||
|
|
||||||
|
bool selected(WidgetTester tester, String label) =>
|
||||||
|
tester.widget<FilterChip>(chip<FilterChip>(label)).selected;
|
||||||
|
|
||||||
|
testWidgets('a tag created in the picker is chosen, and saving gives it '
|
||||||
|
'to the card, which the list then shows', (tester) async {
|
||||||
|
await tagged('猫');
|
||||||
|
await showList(tester);
|
||||||
|
await openCard(tester, '猫');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add tags'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Noun');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Create "noun"'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Create tag'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(selected(tester, 'noun'), isTrue);
|
||||||
|
await tester.tap(find.byTooltip('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(chip<TagChip>('noun'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.widgetWithText(TagLabel, 'noun'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the picker narrows the tags to those starting with what is '
|
||||||
|
'typed, and taking a tag away can be saved', (tester) async {
|
||||||
|
await tagged('猫', ['verb']);
|
||||||
|
await tags.create('noun');
|
||||||
|
await tags.create('adjective');
|
||||||
|
await showList(tester);
|
||||||
|
await openCard(tester, '猫');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Edit tags'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(selected(tester, 'verb'), isTrue);
|
||||||
|
expect(selected(tester, 'noun'), isFalse);
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'v');
|
||||||
|
await tester.pump();
|
||||||
|
expect(chip<FilterChip>('noun'), findsNothing);
|
||||||
|
expect(chip<FilterChip>('adjective'), findsNothing);
|
||||||
|
await tester.tap(chip<FilterChip>('verb'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add tags'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(TagLabel, 'verb'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('managing tags: each with its count; renaming to an existing '
|
||||||
|
'name says it merges; deleting asks and can be undone', (tester) async {
|
||||||
|
await tagged('猫', ['noun']);
|
||||||
|
await tags.create('verb');
|
||||||
|
await showList(tester);
|
||||||
|
await manageTags(tester);
|
||||||
|
|
||||||
|
expect(find.text('noun (1)'), findsOneWidget);
|
||||||
|
expect(find.text('verb (0)'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit tag').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Verb');
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
"There is a tag 'verb' already: saving merges this one into it.",
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'nouns');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('nouns (1)'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Delete tag').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text("Delete the tag 'nouns'?"), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('It is removed from 1 flashcard, on all your devices.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('nouns (1)'), findsNothing);
|
||||||
|
expect(find.text("Deleted the tag 'nouns'"), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Undo'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('nouns (1)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a new tag cannot take a name that exists', (tester) async {
|
||||||
|
await tags.create('noun');
|
||||||
|
await tagged('猫');
|
||||||
|
await showList(tester);
|
||||||
|
await manageTags(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add tag'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField).last, ' Noun');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text("The tag 'noun' exists already."), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the cards narrow to those with a tag, among the tags they '
|
||||||
|
'have, and widen again', (tester) async {
|
||||||
|
await tagged('猫', ['noun']);
|
||||||
|
await tagged('食べる', ['verb']);
|
||||||
|
await tags.create('unused');
|
||||||
|
await showList(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Filter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(chip<ChoiceChip>('unused'), findsNothing);
|
||||||
|
await tester.tap(chip<ChoiceChip>('verb'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Apply'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('食べる'), findsOneWidget);
|
||||||
|
expect(find.text('猫'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Filter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Clear all'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Apply'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('猫'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue