feat(app): the studyset screen with its topics, the tree's own screen
A studyset's screen, as decided with the tree (2026-09-30/10-01):
- A breadcrumb from the library down to the studyset; its steps go up
(replacing the location, so back leads further up), and steps that
don't fit fold into a "…" listing them, nearest first.
- From expanded windows on, two columns: the description over the topics,
the flashcards beside them, Study and Add material in the app bar. Below
that, the topics are in a drawer, opened from a bar at the bottom that
also holds Study and Add material and slides away while reading onwards;
the back button stays where a drawer would have taken its place.
- The topics: "All topics" for the whole studyset, then the studysets
inside it in their order, each with the study it invites, what it holds
and its options (edit, delete), opening on top of this screen; a switch
rearranges them by dragging; "New sub-studyset" creates one inside.
- Study covers the studyset with everything inside it ("Learn 12" while
something is due). Add material offers flashcards for now.
- Options: Edit (the sheet, now with the description, edited in the whole
window, and for a studyset inside another the colour of the one it is
in, which a new one takes at first), Move to… (the tree and the top
level, without the studyset itself and what is inside it), Archive or
Unarchive with Undo, Delete with Undo, which closes the screen.
- The description cut short when long, the whole a tap away; the licence
as the last line, linking to its text where that is known.
The list's bloc, scoped to a parent, lists its children, works out the
studyset's own progress, creates inside it, reorders and moves. The card
list leaves adding and studying to the screen. The inline editing of the
title and description, and its bloc, are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9c2960f49d
commit
31d87e8415
28 changed files with 2597 additions and 541 deletions
|
|
@ -90,7 +90,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
|
||||
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
||||
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
||||
- **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); 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.
|
||||
- **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).
|
||||
|
|
@ -106,6 +106,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **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.
|
||||
- **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.
|
||||
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
|
||||
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
|
||||
- **Widget tests of screens start the whole app**: `pumpApp(tester, testDependencies(...), location: …)` (`test/support/pump_app.dart`) pumps `SessionGate` with the real `PersonApp`, opened at the screen's path, and settles once `PersonScope` has opened the person's data. From there a test moves as a person would: `openTab` (the tab's label alone may also be a screen's title), taps, `pageBack`. Data goes into the fake gateways before pumping; they work in memory (`FakeStudysetGateway`, with `failReads` for the error path), and a repository of the test's own on the same fake gateway writes what the app then reads. `pumpAndSettle` never ends while a spinner shows (`AppLoadingIndicator` animates without end): pump frames instead where one is expected. A widget pumped on its own that reads `AppLocalizations` needs `AppLocalizations.localizationsDelegates` in its `MaterialApp`.
|
||||
|
||||
|
|
@ -127,9 +128,9 @@ 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.
|
||||
- **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.
|
||||
- **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: the library hides a deleted studyset at once and blanks and deletes it only once the time to undo is over (`StudysetListBloc`), and drops a deletion still waiting when it closes. 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. 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.
|
||||
- **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. The study queue (`study_queue.dart`): due cards earliest first, then new cards in studyset order, 20 per studyset and local day. 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.
|
||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"libraryStudysetUnarchived": "Studyset unarchived",
|
||||
"libraryStudysetDeleted": "Studyset deleted",
|
||||
"libraryDeleteTitle": "Delete studyset?",
|
||||
"libraryDeleteMessage": "It is deleted with its cards, on all your devices. You can undo this for a few seconds.",
|
||||
"libraryDeleteMessage": "It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.",
|
||||
"libraryDeleteConfirm": "Delete",
|
||||
|
||||
"studysetSheetCreateTitle": "Create studyset",
|
||||
|
|
@ -61,6 +61,39 @@
|
|||
"studysetMenuArchive": "Archive",
|
||||
"studysetMenuUnarchive": "Unarchive",
|
||||
"studysetMenuDelete": "Delete",
|
||||
"studysetMenuMove": "Move to…",
|
||||
"studysetTabFlashcards": "Flashcards",
|
||||
"studysetFlashcardsEmpty": "No flashcards yet",
|
||||
"studysetFlashcardsEmptyHint": "Add some to start studying.",
|
||||
"studysetAddMaterial": "Add material",
|
||||
"studysetCreateFlashcards": "Create flashcards",
|
||||
"studysetStudy": "Study",
|
||||
"studysetLearnCount": "Learn {count}",
|
||||
"@studysetLearnCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetOpenTopics": "Show topics",
|
||||
"studysetTopics": "Topics",
|
||||
"studysetAllTopics": "All topics",
|
||||
"studysetNewSubStudyset": "New sub-studyset",
|
||||
"studysetSubStudysetsEmpty": "No sub-studysets yet.",
|
||||
"studysetSubStudysetsEmptyHint": "Break this studyset into milestones or lessons.",
|
||||
"studysetReorder": "Reorder",
|
||||
"studysetReorderDone": "Done",
|
||||
"studysetDragToReorder": "Drag to reorder",
|
||||
"studysetMoveTitle": "Move studyset to",
|
||||
"studysetMoveTopLevel": "Top level",
|
||||
"studysetMoveCurrent": "Where it is now",
|
||||
"studysetMoveConfirm": "Move",
|
||||
"studysetMoveShowInside": "Show what is inside",
|
||||
"studysetMoveHideInside": "Hide what is inside",
|
||||
"studysetMoved": "Moved to {destination}.",
|
||||
"@studysetMoved": {"placeholders": {"destination": {"type": "String"}}},
|
||||
"studysetReadMore": "Read more",
|
||||
"studysetDescriptionTitle": "Description",
|
||||
"studysetDescriptionEditTitle": "Edit description",
|
||||
"studysetDescriptionSave": "Save",
|
||||
"studysetSheetDescription": "Description",
|
||||
"studysetSheetDescriptionAdd": "Add a description",
|
||||
"studysetSheetInheritColor": "Same color as the studyset it is in",
|
||||
|
||||
"exploreTitle": "Explore",
|
||||
"exploreEmpty": "Studysets that others share will appear here.",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @libraryDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It is deleted with its cards, on all your devices. You can undo this for a few seconds.'**
|
||||
/// **'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.'**
|
||||
String get libraryDeleteMessage;
|
||||
|
||||
/// No description provided for @libraryDeleteConfirm.
|
||||
|
|
@ -394,6 +394,192 @@ abstract class AppLocalizations {
|
|||
/// **'Delete'**
|
||||
String get studysetMenuDelete;
|
||||
|
||||
/// No description provided for @studysetMenuMove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move to…'**
|
||||
String get studysetMenuMove;
|
||||
|
||||
/// No description provided for @studysetTabFlashcards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Flashcards'**
|
||||
String get studysetTabFlashcards;
|
||||
|
||||
/// No description provided for @studysetFlashcardsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No flashcards yet'**
|
||||
String get studysetFlashcardsEmpty;
|
||||
|
||||
/// No description provided for @studysetFlashcardsEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add some to start studying.'**
|
||||
String get studysetFlashcardsEmptyHint;
|
||||
|
||||
/// No description provided for @studysetAddMaterial.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add material'**
|
||||
String get studysetAddMaterial;
|
||||
|
||||
/// No description provided for @studysetCreateFlashcards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create flashcards'**
|
||||
String get studysetCreateFlashcards;
|
||||
|
||||
/// No description provided for @studysetStudy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study'**
|
||||
String get studysetStudy;
|
||||
|
||||
/// No description provided for @studysetLearnCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learn {count}'**
|
||||
String studysetLearnCount(int count);
|
||||
|
||||
/// No description provided for @studysetOpenTopics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show topics'**
|
||||
String get studysetOpenTopics;
|
||||
|
||||
/// No description provided for @studysetTopics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Topics'**
|
||||
String get studysetTopics;
|
||||
|
||||
/// No description provided for @studysetAllTopics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All topics'**
|
||||
String get studysetAllTopics;
|
||||
|
||||
/// No description provided for @studysetNewSubStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New sub-studyset'**
|
||||
String get studysetNewSubStudyset;
|
||||
|
||||
/// No description provided for @studysetSubStudysetsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No sub-studysets yet.'**
|
||||
String get studysetSubStudysetsEmpty;
|
||||
|
||||
/// No description provided for @studysetSubStudysetsEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Break this studyset into milestones or lessons.'**
|
||||
String get studysetSubStudysetsEmptyHint;
|
||||
|
||||
/// No description provided for @studysetReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder'**
|
||||
String get studysetReorder;
|
||||
|
||||
/// No description provided for @studysetReorderDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get studysetReorderDone;
|
||||
|
||||
/// No description provided for @studysetDragToReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag to reorder'**
|
||||
String get studysetDragToReorder;
|
||||
|
||||
/// No description provided for @studysetMoveTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move studyset to'**
|
||||
String get studysetMoveTitle;
|
||||
|
||||
/// No description provided for @studysetMoveTopLevel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Top level'**
|
||||
String get studysetMoveTopLevel;
|
||||
|
||||
/// No description provided for @studysetMoveCurrent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where it is now'**
|
||||
String get studysetMoveCurrent;
|
||||
|
||||
/// No description provided for @studysetMoveConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move'**
|
||||
String get studysetMoveConfirm;
|
||||
|
||||
/// No description provided for @studysetMoveShowInside.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show what is inside'**
|
||||
String get studysetMoveShowInside;
|
||||
|
||||
/// No description provided for @studysetMoveHideInside.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide what is inside'**
|
||||
String get studysetMoveHideInside;
|
||||
|
||||
/// No description provided for @studysetMoved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Moved to {destination}.'**
|
||||
String studysetMoved(String destination);
|
||||
|
||||
/// No description provided for @studysetReadMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Read more'**
|
||||
String get studysetReadMore;
|
||||
|
||||
/// No description provided for @studysetDescriptionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Description'**
|
||||
String get studysetDescriptionTitle;
|
||||
|
||||
/// No description provided for @studysetDescriptionEditTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit description'**
|
||||
String get studysetDescriptionEditTitle;
|
||||
|
||||
/// No description provided for @studysetDescriptionSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get studysetDescriptionSave;
|
||||
|
||||
/// No description provided for @studysetSheetDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Description'**
|
||||
String get studysetSheetDescription;
|
||||
|
||||
/// No description provided for @studysetSheetDescriptionAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a description'**
|
||||
String get studysetSheetDescriptionAdd;
|
||||
|
||||
/// No description provided for @studysetSheetInheritColor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Same color as the studyset it is in'**
|
||||
String get studysetSheetInheritColor;
|
||||
|
||||
/// No description provided for @exploreTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get libraryDeleteMessage =>
|
||||
'It is deleted with its cards, on all your devices. You can undo this for a few seconds.';
|
||||
'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.';
|
||||
|
||||
@override
|
||||
String get libraryDeleteConfirm => 'Delete';
|
||||
|
|
@ -201,6 +201,104 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetMenuDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get studysetMenuMove => 'Move to…';
|
||||
|
||||
@override
|
||||
String get studysetTabFlashcards => 'Flashcards';
|
||||
|
||||
@override
|
||||
String get studysetFlashcardsEmpty => 'No flashcards yet';
|
||||
|
||||
@override
|
||||
String get studysetFlashcardsEmptyHint => 'Add some to start studying.';
|
||||
|
||||
@override
|
||||
String get studysetAddMaterial => 'Add material';
|
||||
|
||||
@override
|
||||
String get studysetCreateFlashcards => 'Create flashcards';
|
||||
|
||||
@override
|
||||
String get studysetStudy => 'Study';
|
||||
|
||||
@override
|
||||
String studysetLearnCount(int count) {
|
||||
return 'Learn $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetOpenTopics => 'Show topics';
|
||||
|
||||
@override
|
||||
String get studysetTopics => 'Topics';
|
||||
|
||||
@override
|
||||
String get studysetAllTopics => 'All topics';
|
||||
|
||||
@override
|
||||
String get studysetNewSubStudyset => 'New sub-studyset';
|
||||
|
||||
@override
|
||||
String get studysetSubStudysetsEmpty => 'No sub-studysets yet.';
|
||||
|
||||
@override
|
||||
String get studysetSubStudysetsEmptyHint =>
|
||||
'Break this studyset into milestones or lessons.';
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Reorder';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Done';
|
||||
|
||||
@override
|
||||
String get studysetDragToReorder => 'Drag to reorder';
|
||||
|
||||
@override
|
||||
String get studysetMoveTitle => 'Move studyset to';
|
||||
|
||||
@override
|
||||
String get studysetMoveTopLevel => 'Top level';
|
||||
|
||||
@override
|
||||
String get studysetMoveCurrent => 'Where it is now';
|
||||
|
||||
@override
|
||||
String get studysetMoveConfirm => 'Move';
|
||||
|
||||
@override
|
||||
String get studysetMoveShowInside => 'Show what is inside';
|
||||
|
||||
@override
|
||||
String get studysetMoveHideInside => 'Hide what is inside';
|
||||
|
||||
@override
|
||||
String studysetMoved(String destination) {
|
||||
return 'Moved to $destination.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetReadMore => 'Read more';
|
||||
|
||||
@override
|
||||
String get studysetDescriptionTitle => 'Description';
|
||||
|
||||
@override
|
||||
String get studysetDescriptionEditTitle => 'Edit description';
|
||||
|
||||
@override
|
||||
String get studysetDescriptionSave => 'Save';
|
||||
|
||||
@override
|
||||
String get studysetSheetDescription => 'Description';
|
||||
|
||||
@override
|
||||
String get studysetSheetDescriptionAdd => 'Add a description';
|
||||
|
||||
@override
|
||||
String get studysetSheetInheritColor => 'Same color as the studyset it is in';
|
||||
|
||||
@override
|
||||
String get exploreTitle => 'Explore';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
|
|
@ -121,7 +120,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
id: state.pathParameters['cardId']!,
|
||||
items: () => cards.watch(studysetId),
|
||||
idOf: (card) => card.id,
|
||||
builder: (card) => BlocProvider(
|
||||
builder: (card, _) => BlocProvider(
|
||||
create: (context) => CardEditBloc(cards, studysetId, card),
|
||||
child: CardScreen(card: card),
|
||||
),
|
||||
|
|
@ -135,16 +134,9 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
],
|
||||
);
|
||||
|
||||
Widget _studysetScreen(BuildContext context, String id) {
|
||||
final person = PersonScope.of(context);
|
||||
return FoundById<Studyset>(
|
||||
id: id,
|
||||
items: person.studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset) => BlocProvider(
|
||||
create: (context) =>
|
||||
StudysetEditBloc(person.studysets, person.cards, studyset),
|
||||
child: StudysetScreen(studyset: studyset, cards: person.cards),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
|
||||
id: id,
|
||||
items: PersonScope.of(context).studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ class const FoundById<T extends Object>({
|
|||
/// An item's id.
|
||||
required final String Function(T item) idOf,
|
||||
|
||||
/// The screen for the item found.
|
||||
required final Widget Function(T item) builder,
|
||||
/// The screen for the item found, given the whole list too.
|
||||
required final Widget Function(T item, List<T> items) builder,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -30,8 +30,9 @@ class const FoundById<T extends Object>({
|
|||
class _FoundByIdState<T extends Object>() extends State<FoundById<T>> {
|
||||
late final Stream<List<T>> _items = widget.items();
|
||||
|
||||
/// The item as last found.
|
||||
/// The item as last found, and the list it was in.
|
||||
T? _found;
|
||||
List<T> _foundIn = const [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => StreamBuilder<List<T>>(
|
||||
|
|
@ -47,14 +48,15 @@ class _FoundByIdState<T extends Object>() extends State<FoundById<T>> {
|
|||
for (final item in items) {
|
||||
if (widget.idOf(item) == widget.id) {
|
||||
_found = item;
|
||||
return widget.builder(item);
|
||||
_foundIn = items;
|
||||
return widget.builder(item, items);
|
||||
}
|
||||
}
|
||||
// A route stops being active as soon as it is popped, while its
|
||||
// closing transition still shows it.
|
||||
final found = _found;
|
||||
if (found != null && !(ModalRoute.of(context)?.isActive ?? true)) {
|
||||
return widget.builder(found);
|
||||
return widget.builder(found, _foundIn);
|
||||
}
|
||||
return const _Gone();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
|
|
@ -7,8 +8,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
|||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
/// A studyset's cards: in order, draggable, each opening its editor; and a
|
||||
/// way to add one. Lives inside a scrolling screen.
|
||||
/// A studyset's cards: in order, draggable, each opening its editor. Lives
|
||||
/// inside a scrolling screen; adding and studying are the screen's.
|
||||
class const CardList({
|
||||
/// The person's cards.
|
||||
required final CardRepository cards,
|
||||
|
|
@ -21,46 +22,45 @@ class const CardList({
|
|||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
CardListBloc(cards, studysetId)..add(const CardListEventStarted()),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Cards',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.school_outlined),
|
||||
label: const Text('Study'),
|
||||
onPressed: () => context.push(RoutePaths.study(studysetId)),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add card'),
|
||||
onPressed: () => context.push(RoutePaths.newCard(studysetId)),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<CardListBloc, CardListState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
CardListStateLoading() => const SizedBox.shrink(),
|
||||
CardListStateFailed() => const Text("Couldn't read the cards."),
|
||||
CardListStateLoaded(cards: []) => const _NoCards(),
|
||||
CardListStateLoaded(cards: final list) => _Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
list,
|
||||
),
|
||||
BlocBuilder<CardListBloc, CardListState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
CardListStateLoading() => const SizedBox.shrink(),
|
||||
CardListStateFailed() => const Text("Couldn't read the cards."),
|
||||
CardListStateLoaded(cards: []) => const Text('No cards yet.'),
|
||||
CardListStateLoaded(cards: final list) => _Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
list,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// No cards yet, and where they come from.
|
||||
class const _NoCards() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.studysetFlashcardsEmpty, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.studysetFlashcardsEmptyHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Cards(
|
||||
final CardRepository repository,
|
||||
final String studysetId,
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
||||
/// What happens while a studyset is edited.
|
||||
sealed class const StudysetEditEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The title was changed to [title].
|
||||
final class const StudysetEditEventTitleChanged(
|
||||
/// The new title.
|
||||
final String title,
|
||||
) extends StudysetEditEvent {
|
||||
@override
|
||||
List<Object?> get props => [title];
|
||||
}
|
||||
|
||||
/// The description was changed to [description].
|
||||
final class const StudysetEditEventDescriptionChanged(
|
||||
/// The new description.
|
||||
final RichContent description,
|
||||
) extends StudysetEditEvent {
|
||||
@override
|
||||
List<Object?> get props => [description];
|
||||
}
|
||||
|
||||
/// Store the changes.
|
||||
final class const StudysetEditEventSaveRequested() extends StudysetEditEvent;
|
||||
|
||||
/// Delete the studyset; the person has confirmed it.
|
||||
final class const StudysetEditEventDeleteRequested() extends StudysetEditEvent;
|
||||
|
||||
/// A studyset being edited.
|
||||
final class const StudysetEditState({
|
||||
/// The studyset with the changes made so far.
|
||||
required final Studyset studyset,
|
||||
|
||||
/// The studyset as last stored.
|
||||
required final Studyset saved,
|
||||
|
||||
/// Whether the studyset was deleted; the screen closes then.
|
||||
final bool isDeleted = false,
|
||||
}) with Equatable {
|
||||
/// Whether there are changes that aren't stored yet.
|
||||
bool get hasChanges => studyset != saved;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [studyset, saved, isDeleted];
|
||||
}
|
||||
|
||||
/// Edits one studyset: keeps the changes until they are saved, and deletes it
|
||||
/// on request.
|
||||
class StudysetEditBloc(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
Studyset studyset,
|
||||
) extends Bloc<StudysetEditEvent, StudysetEditState> {
|
||||
this : super(StudysetEditState(studyset: studyset, saved: studyset)) {
|
||||
on<StudysetEditEventTitleChanged>(
|
||||
(event, emit) => emit(
|
||||
StudysetEditState(
|
||||
studyset: state.studyset.withContent(
|
||||
state.studyset.content.copyWith(title: event.title),
|
||||
),
|
||||
saved: state.saved,
|
||||
),
|
||||
),
|
||||
);
|
||||
on<StudysetEditEventDescriptionChanged>(
|
||||
(event, emit) => emit(
|
||||
StudysetEditState(
|
||||
studyset: state.studyset.withContent(
|
||||
state.studyset.content.copyWith(description: event.description),
|
||||
),
|
||||
saved: state.saved,
|
||||
),
|
||||
),
|
||||
);
|
||||
on<StudysetEditEventSaveRequested>(_onSaveRequested);
|
||||
on<StudysetEditEventDeleteRequested>(_onDeleteRequested);
|
||||
}
|
||||
|
||||
Future<void> _onSaveRequested(
|
||||
StudysetEditEventSaveRequested event,
|
||||
Emitter<StudysetEditState> emit,
|
||||
) async {
|
||||
final studyset = state.studyset;
|
||||
await _studysets.updateContent(studyset.id, studyset.content);
|
||||
emit(StudysetEditState(studyset: studyset, saved: studyset));
|
||||
}
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
StudysetEditEventDeleteRequested event,
|
||||
Emitter<StudysetEditState> emit,
|
||||
) async {
|
||||
// Its cards first, each blanked, so no card content outlives the
|
||||
// studyset (ADR 0002, rule 2).
|
||||
await _cards.deleteAllOf(state.studyset.id);
|
||||
await _studysets.delete(state.studyset.id);
|
||||
emit(
|
||||
StudysetEditState(
|
||||
studyset: state.studyset,
|
||||
saved: state.saved,
|
||||
isDeleted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,8 @@ sealed class const StudysetListEvent() with Equatable {
|
|||
/// The library opens: follow the person's studysets and their cards.
|
||||
final class const StudysetListEventStarted() extends StudysetListEvent;
|
||||
|
||||
/// Create a studyset with [content].
|
||||
/// Create a studyset with [content], in the library or inside the
|
||||
/// studyset the list shows the children of.
|
||||
final class const StudysetListEventCreateRequested(
|
||||
/// The new studyset's content.
|
||||
final StudysetContent content,
|
||||
|
|
@ -31,6 +32,31 @@ final class const StudysetListEventCreateRequested(
|
|||
List<Object?> get props => [content];
|
||||
}
|
||||
|
||||
/// Move the studyset [id] to [toIndex] among its siblings.
|
||||
final class const StudysetListEventReorderRequested(
|
||||
/// The studyset's id.
|
||||
final String id, {
|
||||
|
||||
/// Where it goes among its siblings.
|
||||
required final int toIndex,
|
||||
}) extends StudysetListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id, toIndex];
|
||||
}
|
||||
|
||||
/// Move the studyset [id] into [parentId], or to the top level when that is
|
||||
/// null.
|
||||
final class const StudysetListEventMoveRequested(
|
||||
/// The studyset's id.
|
||||
final String id, {
|
||||
|
||||
/// Where it goes.
|
||||
required final String? parentId,
|
||||
}) extends StudysetListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id, parentId];
|
||||
}
|
||||
|
||||
/// Replace the content of the studyset [id] with [content].
|
||||
final class const StudysetListEventContentChanged(
|
||||
/// The studyset's id.
|
||||
|
|
@ -70,25 +96,6 @@ final class const StudysetListEventUnarchiveRequested(
|
|||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Delete the studyset [id] with its cards, once the time to undo that is
|
||||
/// over.
|
||||
final class const StudysetListEventDeleteRequested(
|
||||
/// The studyset's id.
|
||||
final String id,
|
||||
) extends StudysetListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Keep the studyset [id] after all, while its deletion still waits.
|
||||
final class const StudysetListEventDeleteUndone(
|
||||
/// The studyset's id.
|
||||
final String id,
|
||||
) extends StudysetListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Work out the studysets' progress again, as after a study session.
|
||||
final class const StudysetListEventProgressRefreshRequested()
|
||||
extends StudysetListEvent;
|
||||
|
|
@ -108,14 +115,16 @@ final class const StudysetListStateFailed() extends StudysetListState {
|
|||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The top-level studysets, starred ones first, each group most recently
|
||||
/// changed first, with their progress.
|
||||
/// The studysets listed, with their progress: in the library, those at the
|
||||
/// top level, starred ones first, each group most recently changed first;
|
||||
/// inside a studyset, its children in their order.
|
||||
final class const StudysetListStateLoaded(
|
||||
/// The studysets at the top level.
|
||||
/// The studysets listed.
|
||||
final List<Studyset> studysets, {
|
||||
|
||||
/// Each studyset's progress, by its id; a studyset without one is still
|
||||
/// being worked out.
|
||||
/// Each studyset's progress, by its id, and inside a studyset also that
|
||||
/// studyset's own, with everything inside it; a studyset without one is
|
||||
/// still being worked out.
|
||||
final Map<String, StudyProgress> progress = const {},
|
||||
}) extends StudysetListState {
|
||||
@override
|
||||
|
|
@ -124,20 +133,32 @@ final class const StudysetListStateLoaded(
|
|||
|
||||
/// The library: the person's studysets with the progress of their cards,
|
||||
/// following every change, local or synced; creates, edits, stars, archives
|
||||
/// and deletes studysets.
|
||||
/// and deletes studysets. Given a [_parentId], it lists that studyset's
|
||||
/// children instead, for its topics, and creates studysets inside it.
|
||||
///
|
||||
/// Deleting can be undone for a moment: [StudysetDeletions] keeps the
|
||||
/// deletion, and the list leaves out what it reports as pending.
|
||||
/// Deleting goes to [StudysetDeletions] directly (see
|
||||
/// `deleteStudysetWithUndo`); the list leaves out what it reports as
|
||||
/// pending.
|
||||
class StudysetListBloc(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetDeletions _deletions,
|
||||
) extends Bloc<StudysetListEvent, StudysetListState> {
|
||||
final StudysetDeletions _deletions, {
|
||||
|
||||
/// The studyset whose children to list; the library's top level when
|
||||
/// null.
|
||||
final String? _parentId,
|
||||
}) extends Bloc<StudysetListEvent, StudysetListState> {
|
||||
this : super(const StudysetListStateLoading()) {
|
||||
on<StudysetListEventStarted>(_onStarted);
|
||||
on<StudysetListEventCreateRequested>(
|
||||
(event, emit) => _studysets.create(event.content),
|
||||
(event, emit) => _studysets.create(event.content, parentId: _parentId),
|
||||
);
|
||||
on<StudysetListEventMoveRequested>(
|
||||
(event, emit) => _studysets.move(event.id, parentId: event.parentId),
|
||||
);
|
||||
on<StudysetListEventReorderRequested>(
|
||||
(event, emit) => _studysets.reorder(event.id, toIndex: event.toIndex),
|
||||
);
|
||||
on<StudysetListEventContentChanged>(
|
||||
(event, emit) => _studysets.updateContent(event.id, event.content),
|
||||
|
|
@ -153,12 +174,6 @@ class StudysetListBloc(
|
|||
on<StudysetListEventUnarchiveRequested>(
|
||||
(event, emit) => _studysets.unarchive(event.id),
|
||||
);
|
||||
on<StudysetListEventDeleteRequested>(
|
||||
(event, emit) => _deletions.delete(event.id),
|
||||
);
|
||||
on<StudysetListEventDeleteUndone>(
|
||||
(event, emit) => _deletions.undo(event.id),
|
||||
);
|
||||
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
|
||||
}
|
||||
|
||||
|
|
@ -172,10 +187,12 @@ class StudysetListBloc(
|
|||
/// The studysets whose deletion waits.
|
||||
var _pendingDeletions = const <String>{};
|
||||
|
||||
/// The studysets the library shows: in its order, without those whose
|
||||
/// deletion waits.
|
||||
/// The studysets listed, in order, without those whose deletion waits.
|
||||
List<Studyset> get _shown => [
|
||||
for (final studyset in _inLibraryOrder(_studysetsRead))
|
||||
for (final studyset in switch (_parentId) {
|
||||
null => _inLibraryOrder(_studysetsRead),
|
||||
final parentId => childrenOf(parentId, _studysetsRead),
|
||||
})
|
||||
if (!_pendingDeletions.contains(studyset.id)) studyset,
|
||||
];
|
||||
|
||||
|
|
@ -224,7 +241,10 @@ class StudysetListBloc(
|
|||
final now = clock.now().toUtc();
|
||||
final progress = <String, StudyProgress>{};
|
||||
try {
|
||||
for (final studyset in studysets) {
|
||||
final parent = _studysetsRead
|
||||
.where((studyset) => studyset.id == _parentId)
|
||||
.firstOrNull;
|
||||
for (final studyset in [...studysets, ?parent]) {
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
final studied = studyset.isArchived
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
/// Where a licence's text is, for a studyset's licence line: the deed of a
|
||||
/// Creative Commons licence (`CC-BY-SA-4.0`, `CC-BY-NC-SA-3.0-US`,
|
||||
/// `CC0-1.0`), SPDX's page of a few other well-known licences; null for one
|
||||
/// it doesn't know, which then shows without a link.
|
||||
Uri? licenseUrl(String license) {
|
||||
if (license == 'CC0-1.0') {
|
||||
return Uri.parse('https://creativecommons.org/publicdomain/zero/1.0/');
|
||||
}
|
||||
if (_creativeCommons.firstMatch(license) case final match?) {
|
||||
final kind = match[1]!.toLowerCase();
|
||||
final version = match[2]!;
|
||||
final jurisdiction = switch (match[3]) {
|
||||
final code? => '${code.toLowerCase()}/',
|
||||
null => '',
|
||||
};
|
||||
return Uri.parse(
|
||||
'https://creativecommons.org/licenses/$kind/$version/$jurisdiction',
|
||||
);
|
||||
}
|
||||
if (_spdx.contains(license)) {
|
||||
return Uri.parse('https://spdx.org/licenses/$license.html');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A Creative Commons licence's SPDX identifier: its kind, version and
|
||||
/// jurisdiction, if any.
|
||||
final _creativeCommons = RegExp(
|
||||
r'^CC-(BY(?:-NC)?(?:-SA|-ND)?)-(\d\.\d)(?:-([A-Z]{2,}))?$',
|
||||
);
|
||||
|
||||
/// Other licences content commonly comes under, by SPDX identifier.
|
||||
const _spdx = {
|
||||
'MIT',
|
||||
'Apache-2.0',
|
||||
'BSD-3-Clause',
|
||||
'GPL-3.0-or-later',
|
||||
'AGPL-3.0-or-later',
|
||||
};
|
||||
|
|
@ -102,3 +102,18 @@ int _bySiblingOrder(Studyset a, Studyset b) =>
|
|||
0 => a.id.compareTo(b.id),
|
||||
final order => order,
|
||||
};
|
||||
|
||||
/// The colour the studyset [id] shows: its own, or the nearest one of the
|
||||
/// studysets it sits in; null when none of them has one.
|
||||
String? colorNameOf(String id, List<Studyset> all) {
|
||||
final studyset = all.where((each) => each.id == id).firstOrNull;
|
||||
if (studyset?.content.colorName case final own?) {
|
||||
return own;
|
||||
}
|
||||
for (final ancestor in ancestorsOf(id, all).reversed) {
|
||||
if (ancestor.content.colorName case final inherited?) {
|
||||
return inherited;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
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/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Asks before deleting [studyset] with everything inside it, then hands the
|
||||
/// deletion to the person's `StudysetDeletions` and offers to undo it for a
|
||||
/// moment; [onDeleted] runs once the person has confirmed, as to close the
|
||||
/// deleted studyset's own screen.
|
||||
///
|
||||
/// Deleting and undoing go to the deletions directly rather than through a
|
||||
/// bloc: the deletion outlives the screen it started on, and so must its
|
||||
/// Undo.
|
||||
Future<void> deleteStudysetWithUndo(
|
||||
BuildContext context,
|
||||
Studyset studyset, {
|
||||
VoidCallback? onDeleted,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deletions = PersonScope.of(context).deletions;
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.libraryDeleteTitle,
|
||||
message: l10n.libraryDeleteMessage,
|
||||
confirmLabel: l10n.libraryDeleteConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
deletions.delete(studyset.id);
|
||||
// Shown before [onDeleted] closes anything: the message belongs to the
|
||||
// whole app and stays while screens close.
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.libraryStudysetDeleted,
|
||||
onUndo: () => deletions.undo(studyset.id),
|
||||
);
|
||||
onDeleted?.call();
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
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/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Edits a studyset's description in the whole window, over the sheet that
|
||||
/// opened it; completes with the new description once saved, or with null
|
||||
/// when left without saving.
|
||||
Future<RichContent?> editDescription(
|
||||
BuildContext context,
|
||||
RichContent description,
|
||||
) => Navigator.of(context).push<RichContent>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => _DescriptionEditor(description),
|
||||
),
|
||||
);
|
||||
|
||||
class const _DescriptionEditor(final RichContent initial)
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_DescriptionEditor> createState() => _DescriptionEditorState();
|
||||
}
|
||||
|
||||
class _DescriptionEditorState() extends State<_DescriptionEditor> {
|
||||
late var _description = widget.initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.studysetDescriptionEditTitle),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_description),
|
||||
child: Text(l10n.studysetDescriptionSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The editor gets a height of its own in a scrolling body: stretched
|
||||
// to fill an unbounded column, it never finishes laying out.
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: math.max(320, constraints.maxHeight - 32),
|
||||
child: RichContentEditor(
|
||||
initialContent: widget.initial,
|
||||
onChanged: (description) => _description = description,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,12 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -314,24 +314,8 @@ class _GridState() extends State<_Grid> {
|
|||
bloc.add(StudysetListEventArchiveRequested(studyset.id)),
|
||||
);
|
||||
},
|
||||
// Asks, then offers to undo for a moment before the deletion happens.
|
||||
onDelete: () async {
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.libraryDeleteTitle,
|
||||
message: l10n.libraryDeleteMessage,
|
||||
confirmLabel: l10n.libraryDeleteConfirm,
|
||||
) &&
|
||||
mounted) {
|
||||
bloc.add(StudysetListEventDeleteRequested(studyset.id));
|
||||
// The grid's context, not the card's: the card is about to go.
|
||||
showUndoSnackBar(
|
||||
this.context,
|
||||
message: l10n.libraryStudysetDeleted,
|
||||
onUndo: () => bloc.add(StudysetListEventDeleteUndone(studyset.id)),
|
||||
);
|
||||
}
|
||||
},
|
||||
// The grid's context, not the card's: the card is about to go.
|
||||
onDelete: () => deleteStudysetWithUndo(this.context, studyset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// A studyset's licence, as one dim line at the bottom of its screen:
|
||||
/// "© 2006 Tae Kim · CC-BY-SA-4.0 ↗", the licence linking to its text where
|
||||
/// that is known. Nothing shows while the studyset has neither a licence nor
|
||||
/// a holder nor a year.
|
||||
class const LicenseFooter({
|
||||
/// The studyset's content, holding its licence.
|
||||
required final StudysetContent content,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final license = content.license?.trim() ?? '';
|
||||
final holder = content.licenseHolder?.trim() ?? '';
|
||||
final year = content.licenseYear;
|
||||
if (license.isEmpty && holder.isEmpty && year == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
// The year sits between the symbol and the holder, as copyright notices
|
||||
// put it, and either can stand alone.
|
||||
final credit = [
|
||||
if (year != null) '$year',
|
||||
if (holder.isNotEmpty) holder,
|
||||
].join(' ');
|
||||
final url = license.isEmpty ? null : licenseUrl(license);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (credit.isNotEmpty) Text('© $credit', style: muted),
|
||||
if (credit.isNotEmpty && license.isNotEmpty)
|
||||
Text(' · ', style: muted),
|
||||
if (license.isNotEmpty)
|
||||
if (url == null)
|
||||
Text(license, style: muted)
|
||||
else
|
||||
InkWell(
|
||||
onTap: () => launchUrl(url),
|
||||
child: Text(
|
||||
'$license ↗',
|
||||
style: muted?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:go_router/go_router.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/feature/studyset/model/studyset.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where a studyset sits, on one line: "Library › Japanese › Kana ›
|
||||
/// Hiragana", the studyset itself last, in the primary colour.
|
||||
///
|
||||
/// Every step before it leads there by replacing the location rather than
|
||||
/// pushing it, so the back button then leads further up, not back down.
|
||||
/// When the line doesn't fit, the first steps fold into a "…" that lists
|
||||
/// them, nearest first; the studyset itself always shows.
|
||||
class const StudysetBreadcrumb({
|
||||
/// The studysets it sits in, from the top level down.
|
||||
required final List<Studyset> ancestors,
|
||||
|
||||
/// The studyset.
|
||||
required final Studyset current,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The room a separator takes: the chevron and the space around it.
|
||||
static const _separatorWidth = 24.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final style = theme.textTheme.bodyMedium;
|
||||
final steps = <_Step>[
|
||||
_Step(l10n.libraryTitle, () => context.go(RoutePaths.library)),
|
||||
for (final ancestor in ancestors)
|
||||
_Step(
|
||||
ancestor.content.title,
|
||||
() => context.go(RoutePaths.studyset(ancestor.id)),
|
||||
),
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scale = MediaQuery.textScalerOf(context);
|
||||
double widthOf(String label) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: label, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: scale,
|
||||
)..layout();
|
||||
final width = painter.width;
|
||||
painter.dispose();
|
||||
return width + 8;
|
||||
}
|
||||
|
||||
final widths = [for (final step in steps) widthOf(step.label)];
|
||||
final currentWidth = widthOf(current.content.title);
|
||||
final foldWidth = widthOf('…') + 16;
|
||||
// Fold the first steps away until the rest fits; at the least, the
|
||||
// fold and the studyset itself remain.
|
||||
var folded = 0;
|
||||
double widthFrom(int first) =>
|
||||
(first > 0 ? foldWidth + _separatorWidth : 0) +
|
||||
widths.skip(first).fold(0.0, (sum, w) => sum + w) +
|
||||
(steps.length - first) * _separatorWidth +
|
||||
currentWidth;
|
||||
while (folded < steps.length &&
|
||||
widthFrom(folded) > constraints.maxWidth) {
|
||||
folded++;
|
||||
}
|
||||
final separator = Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
if (folded > 0) ...[
|
||||
_Fold(steps: steps.take(folded).toList().reversed.toList()),
|
||||
SizedBox(
|
||||
width: _separatorWidth,
|
||||
child: Center(child: separator),
|
||||
),
|
||||
],
|
||||
for (final step in steps.skip(
|
||||
math.min(folded, steps.length),
|
||||
)) ...[
|
||||
InkWell(
|
||||
onTap: step.onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
step.label,
|
||||
maxLines: 1,
|
||||
style: style?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _separatorWidth,
|
||||
child: Center(child: separator),
|
||||
),
|
||||
],
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
current.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A step of the way, and where it leads.
|
||||
class const _Step(final String label, final VoidCallback onTap);
|
||||
|
||||
/// The steps that didn't fit, behind a "…", nearest first.
|
||||
class const _Fold({required final List<_Step> steps}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return PopupMenuButton<_Step>(
|
||||
tooltip: '',
|
||||
onSelected: (step) => step.onTap(),
|
||||
itemBuilder: (context) => [
|
||||
for (final step in steps)
|
||||
PopupMenuItem(value: step, child: Text(step.label)),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
'…',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where to move a studyset: into the studyset `parentId`, or to the top
|
||||
/// level when that is null.
|
||||
typedef MoveDestination = ({String? parentId});
|
||||
|
||||
/// Asks where to move [studyset] among [all] the person's studysets: into
|
||||
/// another one, shown as the tree, or to the top level. Completes with the
|
||||
/// destination once the person moves it, or with null when they close the
|
||||
/// sheet.
|
||||
///
|
||||
/// The studyset itself and everything inside it can't be chosen: a studyset
|
||||
/// inside itself would be a cycle. Where it is now is marked, and choosing
|
||||
/// it again moves nothing.
|
||||
Future<MoveDestination?> showMoveSheet(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
}) => showAdaptiveSheet<MoveDestination>(
|
||||
context,
|
||||
builder: (context) => _MoveSheet(studyset: studyset, all: all),
|
||||
);
|
||||
|
||||
class const _MoveSheet({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_MoveSheet> createState() => _MoveSheetState();
|
||||
}
|
||||
|
||||
class _MoveSheetState() extends State<_MoveSheet> {
|
||||
MoveDestination? _destination;
|
||||
|
||||
/// The studysets whose children show.
|
||||
final _open = <String>{};
|
||||
|
||||
/// The studyset and everything inside it, which can't take it.
|
||||
late final _excluded = subtreeOf(widget.studyset.id, widget.all).toSet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final current = widget.studyset.parentId;
|
||||
final destination = _destination;
|
||||
return SheetScaffold(
|
||||
title: l10n.studysetMoveTitle,
|
||||
footer: FilledButton(
|
||||
onPressed: destination == null || destination.parentId == current
|
||||
? null
|
||||
: () => Navigator.of(context).pop(destination),
|
||||
child: Text(l10n.studysetMoveConfirm),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Row(
|
||||
depth: 0,
|
||||
icon: LucideIcons.house,
|
||||
label: l10n.studysetMoveTopLevel,
|
||||
isCurrent: current == null,
|
||||
selected: destination != null && destination.parentId == null,
|
||||
onTap: () => setState(() => _destination = (parentId: null)),
|
||||
),
|
||||
for (final studyset in topLevel(widget.all)) ..._branch(studyset, 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [studyset]'s row and, while it is open, its children's, indented by
|
||||
/// [depth].
|
||||
List<Widget> _branch(Studyset studyset, int depth) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final children = childrenOf(studyset.id, widget.all);
|
||||
final open = _open.contains(studyset.id);
|
||||
return [
|
||||
_Row(
|
||||
depth: depth,
|
||||
icon: LucideIcons.book,
|
||||
label: studyset.content.title,
|
||||
isCurrent: studyset.id == widget.studyset.parentId,
|
||||
selected: _destination?.parentId == studyset.id,
|
||||
onTap: _excluded.contains(studyset.id)
|
||||
? null
|
||||
: () => setState(() => _destination = (parentId: studyset.id)),
|
||||
toggle: children.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: Icon(
|
||||
open ? LucideIcons.chevronDown : LucideIcons.chevronRight,
|
||||
),
|
||||
tooltip: open
|
||||
? l10n.studysetMoveHideInside
|
||||
: l10n.studysetMoveShowInside,
|
||||
onPressed: () => setState(
|
||||
() =>
|
||||
open ? _open.remove(studyset.id) : _open.add(studyset.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (open)
|
||||
for (final child in children) ..._branch(child, depth + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A place to move to, indented by [depth]; disabled without [onTap].
|
||||
class const _Row({
|
||||
required final int depth,
|
||||
required final IconData icon,
|
||||
required final String label,
|
||||
required final bool isCurrent,
|
||||
required final bool selected,
|
||||
required final VoidCallback? onTap,
|
||||
final Widget? toggle,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsetsDirectional.only(start: 8.0 + 24 * depth),
|
||||
enabled: onTap != null,
|
||||
selected: selected,
|
||||
leading: Icon(icon),
|
||||
title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: isCurrent ? Text(l10n.studysetMoveCurrent) : null,
|
||||
trailing: toggle,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A studyset's topics: the studysets inside it, from the list's bloc
|
||||
/// scoped to [parent].
|
||||
///
|
||||
/// On top, while there are any, "All topics": the whole studyset with
|
||||
/// everything inside it, and the study it invites. Below, the topics in
|
||||
/// their order, each opening its own screen on top of this one, with the
|
||||
/// study it invites and its options; a switch rearranges them by dragging.
|
||||
/// Without topics, an invitation to create the first.
|
||||
class const StudysetRail({
|
||||
/// The studyset whose topics these are.
|
||||
required final Studyset parent,
|
||||
|
||||
/// All the person's studysets, for the colours topics take.
|
||||
required final List<Studyset> all,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<StudysetRail> createState() => _StudysetRailState();
|
||||
}
|
||||
|
||||
class _StudysetRailState() extends State<StudysetRail> {
|
||||
var _reordering = false;
|
||||
|
||||
Future<void> _create() async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final content = await showStudysetSheet(
|
||||
context,
|
||||
insideAnother: true,
|
||||
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
|
||||
);
|
||||
if (content != null) {
|
||||
bloc.add(StudysetListEventCreateRequested(content));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _edit(Studyset topic) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final content = await showStudysetSheet(
|
||||
context,
|
||||
initial: topic.content,
|
||||
insideAnother: true,
|
||||
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
|
||||
);
|
||||
if (content != null && content != topic.content) {
|
||||
bloc.add(StudysetListEventContentChanged(topic.id, content));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens [studyset]'s screen, closing the drawer it may be in.
|
||||
void _open(Studyset studyset) {
|
||||
if (Scaffold.maybeOf(context) case final scaffold?
|
||||
when scaffold.isDrawerOpen) {
|
||||
scaffold.closeDrawer();
|
||||
}
|
||||
context.push(RoutePaths.studyset(studyset.id));
|
||||
}
|
||||
|
||||
Future<void> _study(Studyset studyset) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
await context.push(RoutePaths.study(studyset.id));
|
||||
bloc.add(const StudysetListEventProgressRefreshRequested());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return BlocBuilder<StudysetListBloc, StudysetListState>(
|
||||
builder: (context, state) {
|
||||
final topics = switch (state) {
|
||||
StudysetListStateLoaded(:final studysets) => studysets,
|
||||
_ => const <Studyset>[],
|
||||
};
|
||||
final progress = switch (state) {
|
||||
StudysetListStateLoaded(:final progress) => progress,
|
||||
_ => const <String, StudyProgress>{},
|
||||
};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The whole studyset, above the topics it adds up.
|
||||
if (topics.isNotEmpty && !_reordering)
|
||||
_AllTopics(
|
||||
parent: widget.parent,
|
||||
progress: progress[widget.parent.id],
|
||||
onStudy: () => _study(widget.parent),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: Icon(
|
||||
LucideIcons.layers,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(l10n.studysetTopics, style: theme.textTheme.titleSmall),
|
||||
if (topics.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
_Count(topics.length),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: topics.isEmpty && !_reordering
|
||||
? null
|
||||
: IconButton(
|
||||
icon: Icon(
|
||||
_reordering
|
||||
? LucideIcons.check
|
||||
: LucideIcons.arrowUpDown,
|
||||
),
|
||||
tooltip: _reordering
|
||||
? l10n.studysetReorderDone
|
||||
: l10n.studysetReorder,
|
||||
onPressed: () =>
|
||||
setState(() => _reordering = !_reordering),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (state) {
|
||||
StudysetListStateLoading() => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
),
|
||||
StudysetListStateFailed() => Center(
|
||||
child: Text(l10n.libraryUnreadable),
|
||||
),
|
||||
StudysetListStateLoaded(studysets: []) => _NoTopics(
|
||||
onCreate: _create,
|
||||
),
|
||||
StudysetListStateLoaded() when _reordering =>
|
||||
ReorderableListView(
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: (from, to) =>
|
||||
context.read<StudysetListBloc>().add(
|
||||
StudysetListEventReorderRequested(
|
||||
topics[from].id,
|
||||
toIndex: to,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
for (final (index, topic) in topics.indexed)
|
||||
ListTile(
|
||||
key: ValueKey(topic.id),
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
title: TitleWithKicker(
|
||||
title: topic.content.title,
|
||||
kicker: topic.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: IconButton(
|
||||
tooltip: l10n.studysetDragToReorder,
|
||||
icon: const Icon(LucideIcons.gripVertical),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StudysetListStateLoaded() => ListView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
for (final topic in topics)
|
||||
_Topic(
|
||||
key: ValueKey(topic.id),
|
||||
topic: topic,
|
||||
progress: progress[topic.id],
|
||||
onOpen: () => _open(topic),
|
||||
onStudy: () => _study(topic),
|
||||
onEdit: () => _edit(topic),
|
||||
onDelete: () => deleteStudysetWithUndo(
|
||||
this.context,
|
||||
topic,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
onPressed: _create,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.studysetNewSubStudyset),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many topics there are, as a small chip.
|
||||
class const _Count(final int count) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole studyset with everything inside it: what it holds, and the
|
||||
/// study it invites.
|
||||
class const _AllTopics({
|
||||
required final Studyset parent,
|
||||
required final StudyProgress? progress,
|
||||
required final VoidCallback onStudy,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final due = progress?.due ?? 0;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
leading: StudyActionIcon(
|
||||
progress: progress,
|
||||
archived: parent.isArchived,
|
||||
onStudy: onStudy,
|
||||
onAdd: null,
|
||||
),
|
||||
title: Text(l10n.studysetAllTopics),
|
||||
subtitle: ContentCountsLine(
|
||||
counts: [(count: progress?.cards, label: l10n.studysetCountFlashcards)],
|
||||
placeholder: progress == null ? '' : l10n.studysetNoContent,
|
||||
),
|
||||
onTap: due > 0 && !parent.isArchived ? onStudy : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A topic: the study it invites, its title, what it holds, and its
|
||||
/// options.
|
||||
class const _Topic({
|
||||
required final Studyset topic,
|
||||
required final StudyProgress? progress,
|
||||
required final VoidCallback onOpen,
|
||||
required final VoidCallback onStudy,
|
||||
required final VoidCallback onEdit,
|
||||
required final VoidCallback onDelete,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final progress = this.progress;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: Material(
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
StudyActionIcon(
|
||||
progress: progress,
|
||||
archived: topic.isArchived,
|
||||
onStudy: onStudy,
|
||||
onAdd: onOpen,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TitleWithKicker(
|
||||
title: topic.content.title,
|
||||
kicker: topic.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
),
|
||||
if (progress != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ContentCountsLine(
|
||||
counts: [
|
||||
(
|
||||
count: progress.cards,
|
||||
label: l10n.studysetCountFlashcards,
|
||||
),
|
||||
],
|
||||
placeholder: l10n.studysetNoContent,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => option(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: onEdit,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.pencil),
|
||||
title: Text(l10n.studysetMenuEdit),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: onDelete,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.trash2),
|
||||
title: Text(l10n.studysetMenuDelete),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The study a studyset invites, as a round icon: a review when reviews are
|
||||
/// due, studying when only new cards are, adding content otherwise (where
|
||||
/// [onAdd] is given), and a pause while it rests in the archive.
|
||||
class const StudyActionIcon({
|
||||
required final StudyProgress? progress,
|
||||
required final bool archived,
|
||||
required final VoidCallback onStudy,
|
||||
required final VoidCallback? onAdd,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, tooltip, onTap) = switch (progress) {
|
||||
_ when archived => (
|
||||
LucideIcons.circlePause,
|
||||
l10n.studysetArchived,
|
||||
null,
|
||||
),
|
||||
StudyProgress(reviewsDue: > 0) => (
|
||||
LucideIcons.glasses,
|
||||
l10n.studysetActionReview,
|
||||
onStudy,
|
||||
),
|
||||
StudyProgress(due: > 0) => (
|
||||
LucideIcons.play,
|
||||
l10n.studysetActionStudy,
|
||||
onStudy,
|
||||
),
|
||||
_ => (LucideIcons.plus, l10n.studysetActionAdd, onAdd),
|
||||
};
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: scheme.secondaryContainer,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(icon, size: 20, color: scheme.onSecondaryContainer),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// No topics yet: an invitation to create the first.
|
||||
class const _NoTopics({required final VoidCallback onCreate})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.studysetSubStudysetsEmpty,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.studysetSubStudysetsEmptyHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCreate,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.studysetNewSubStudyset),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +1,612 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:go_router/go_router.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/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.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/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows and edits one studyset: its title and description, saved on request;
|
||||
/// deleting it asks first.
|
||||
/// A studyset's screen: where it sits, what it is about, its topics and its
|
||||
/// flashcards, and what can be done with it.
|
||||
///
|
||||
/// From expanded windows on, two columns: the description over the topics
|
||||
/// on the left, the flashcards on the right, and Study and Add material in
|
||||
/// the app bar. Below that, the topics are in a drawer, the description
|
||||
/// sits above the flashcards, and Study and Add material float at the
|
||||
/// bottom, with the button that opens the topics, sliding away while
|
||||
/// reading onwards. The licence, if any, is the last line.
|
||||
class const StudysetScreen({
|
||||
/// The studyset as the screen opens; the bloc holds later changes.
|
||||
/// The studyset.
|
||||
required final Studyset studyset,
|
||||
|
||||
/// The person's cards, for the card list.
|
||||
required final CardRepository cards,
|
||||
/// All the person's studysets, for where it sits and where it can move.
|
||||
required final List<Studyset> all,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocListener<StudysetEditBloc, StudysetEditState>(
|
||||
listenWhen: (previous, current) => current.isDeleted,
|
||||
listener: (context, state) => Navigator.of(context).pop(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Studyset'),
|
||||
actions: [
|
||||
BlocSelector<StudysetEditBloc, StudysetEditState, bool>(
|
||||
selector: (state) => state.hasChanges,
|
||||
builder: (context, hasChanges) => IconButton(
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
tooltip: 'Save',
|
||||
onPressed: hasChanges
|
||||
? () => context.read<StudysetEditBloc>().add(
|
||||
const StudysetEditEventSaveRequested(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final bloc = context.read<StudysetEditBloc>();
|
||||
if (await _confirmDelete(context)) {
|
||||
bloc.add(const StudysetEditEventDeleteRequested());
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
initialValue: studyset.content.title,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
onChanged: (title) => context.read<StudysetEditBloc>().add(
|
||||
StudysetEditEventTitleChanged(title),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
SizedBox(
|
||||
height: 320,
|
||||
child: RichContentEditor(
|
||||
initialContent: studyset.content.description,
|
||||
onChanged: (description) => context
|
||||
.read<StudysetEditBloc>()
|
||||
.add(StudysetEditEventDescriptionChanged(description)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CardList(cards: cards, studysetId: studyset.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
final person = PersonScope.of(context);
|
||||
return BlocProvider(
|
||||
// Its topics, and the progress of the whole studyset.
|
||||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
parentId: studyset.id,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: _StudysetView(studyset: studyset, all: all),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks before deleting a studyset.
|
||||
Future<bool> _confirmDelete(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete this studyset?'),
|
||||
content: const Text('It is deleted on all your devices.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
class const _StudysetView({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
}) extends StatelessWidget {
|
||||
Future<void> _study(BuildContext context) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
await context.push(RoutePaths.study(studyset.id));
|
||||
bloc.add(const StudysetListEventProgressRefreshRequested());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final wide = context.windowSize.isAtLeastExpanded;
|
||||
final progress = context.select<StudysetListBloc, StudyProgress?>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) => progress[studyset.id],
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
final rail = StudysetRail(parent: studyset, all: all);
|
||||
final content = _Content(studysetId: studyset.id);
|
||||
final description = studyset.content.description.plainText.trim().isEmpty
|
||||
? null
|
||||
: _Description(studyset: studyset);
|
||||
final canGoBack = context.canPop();
|
||||
return ScrollHidingFab(
|
||||
fab: wide
|
||||
? null
|
||||
: _StudyBar(
|
||||
studysetId: studyset.id,
|
||||
progress: progress,
|
||||
onStudy: () => _study(context),
|
||||
),
|
||||
builder: (context, fab) => Scaffold(
|
||||
backgroundColor: theme.colorScheme.surfaceContainerLow,
|
||||
// The topics' home below expanded windows.
|
||||
drawer: wide ? null : Drawer(child: SafeArea(child: rail)),
|
||||
appBar: AppBar(
|
||||
// A drawer would take the back button's place; the bar at the
|
||||
// bottom opens the topics instead.
|
||||
leading: !wide && canGoBack ? const BackButton() : null,
|
||||
title: TitleWithKicker(
|
||||
title: studyset.content.title,
|
||||
kicker: studyset.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
titleStyle:
|
||||
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
|
||||
),
|
||||
actions: [
|
||||
if (wide) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _StudyPill(
|
||||
progress: progress,
|
||||
archived: studyset.isArchived,
|
||||
floating: false,
|
||||
onStudy: () => _study(context),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _AddMaterial(
|
||||
studysetId: studyset.id,
|
||||
builder: (context, menu) => FilledButton.tonalIcon(
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(
|
||||
AppLocalizations.of(context).studysetAddMaterial,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
_Options(studyset: studyset, all: all),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: fab,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
StudysetBreadcrumb(
|
||||
ancestors: ancestorsOf(studyset.id, all),
|
||||
current: studyset,
|
||||
),
|
||||
if (!wide && description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: SectionCard(child: description),
|
||||
),
|
||||
Expanded(
|
||||
child: wide
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (description != null) ...[
|
||||
SectionCard(child: description),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: SectionCard(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: rail,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
child: SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
LicenseFooter(content: studyset.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The studyset's content, in tabs that appear as their features land:
|
||||
/// flashcards for now.
|
||||
class const _Content({required final String studysetId})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return DefaultTabController(
|
||||
length: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [Tab(text: l10n.studysetTabFlashcards)],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
ListView(
|
||||
// Room below the last card for the bar floating over it.
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
children: [
|
||||
CardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: studysetId,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The studyset's description, cut short when long, with the whole of it a
|
||||
/// tap away.
|
||||
class const _Description({required final Studyset studyset})
|
||||
extends StatelessWidget {
|
||||
/// How tall the description shows before it is cut short.
|
||||
static const _height = 120.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final description = studyset.content.description;
|
||||
// Long enough to be cut short: guessed from its text, which the view's
|
||||
// own height can't be measured against before it is laid out.
|
||||
final text = description.plainText.trim();
|
||||
final long = text.length > 240 || '\n'.allMatches(text).length > 3;
|
||||
final view = RichContentView(content: description);
|
||||
if (!long) {
|
||||
return view;
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _height,
|
||||
child: ShaderMask(
|
||||
// Fades out the last lines into the card.
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black, Colors.black, Colors.transparent],
|
||||
stops: [0, 0.7, 1],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topLeft,
|
||||
maxHeight: double.infinity,
|
||||
child: view,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
onPressed: () => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.studysetDescriptionTitle,
|
||||
body: RichContentView(content: description),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.studysetReadMore),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Studying the studyset with everything inside it: "Learn 12" while
|
||||
/// something is due, "Study" and disabled when nothing is.
|
||||
class const _StudyPill({
|
||||
required final StudyProgress? progress,
|
||||
required final bool archived,
|
||||
required final bool floating,
|
||||
required final VoidCallback onStudy,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final due = archived ? 0 : progress?.due ?? 0;
|
||||
final enabled = due > 0;
|
||||
final foreground = enabled
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurface.withValues(alpha: 0.38);
|
||||
return Material(
|
||||
elevation: enabled && floating ? 6 : 0,
|
||||
color: enabled
|
||||
? scheme.primaryContainer
|
||||
: scheme.onSurface.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onStudy : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.brain, size: 18, color: foreground),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
enabled ? l10n.studysetLearnCount(due) : l10n.studysetStudy,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What can be added to the studyset, behind [builder]'s trigger:
|
||||
/// flashcards for now; paths, notes and files come with their features.
|
||||
class const _AddMaterial({
|
||||
required final String studysetId,
|
||||
required final Widget Function(BuildContext context, MenuController menu)
|
||||
builder,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.plus),
|
||||
onPressed: () => context.push(RoutePaths.newCard(studysetId)),
|
||||
child: Text(l10n.studysetCreateFlashcards),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => builder(context, menu),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Below expanded windows, at the bottom: the button that opens the topics
|
||||
/// on the left, and Add material and Study centred.
|
||||
class const _StudyBar({
|
||||
required final String studysetId,
|
||||
required final StudyProgress? progress,
|
||||
required final VoidCallback onStudy,
|
||||
}) extends StatelessWidget {
|
||||
static const _margin = 16.0;
|
||||
static const _button = 48.0;
|
||||
static const _gap = 24.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = math.max<double>(0, constraints.maxWidth - 2 * _margin);
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Kept off the topics button's lane on both sides, so the
|
||||
// pair sits in the bar's middle.
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.max(0, width - 2 * (_button + _gap)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_AddMaterial(
|
||||
studysetId: studysetId,
|
||||
builder: (context, menu) => Tooltip(
|
||||
message: l10n.studysetAddMaterial,
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: scheme.secondaryContainer,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
menu.isOpen ? menu.close() : menu.open(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
color: scheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: _StudyPill(
|
||||
progress: progress,
|
||||
archived: false,
|
||||
floating: true,
|
||||
onStudy: onStudy,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: l10n.studysetOpenTopics,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
color: scheme.surface,
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => Scaffold.of(context).openDrawer(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
LucideIcons.panelLeftOpen,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Option() {
|
||||
edit,
|
||||
move,
|
||||
archive,
|
||||
unarchive,
|
||||
delete,
|
||||
}
|
||||
|
||||
/// The studyset's options: edit, move, archive or unarchive, delete.
|
||||
class const _Options({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
}) extends StatelessWidget {
|
||||
Future<void> _edit(BuildContext context) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final parentId = studyset.parentId;
|
||||
final content = await showStudysetSheet(
|
||||
context,
|
||||
initial: studyset.content,
|
||||
insideAnother: parentId != null,
|
||||
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
|
||||
);
|
||||
if (content != null && content != studyset.content) {
|
||||
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _move(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final destination = await showMoveSheet(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
);
|
||||
if (destination == null) {
|
||||
return;
|
||||
}
|
||||
bloc.add(
|
||||
StudysetListEventMoveRequested(
|
||||
studyset.id,
|
||||
parentId: destination.parentId,
|
||||
),
|
||||
);
|
||||
final name = switch (destination.parentId) {
|
||||
null => l10n.studysetMoveTopLevel,
|
||||
final parentId =>
|
||||
all.where((each) => each.id == parentId).firstOrNull?.content.title ??
|
||||
'',
|
||||
};
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.studysetMoved(name))));
|
||||
}
|
||||
|
||||
void _archive(BuildContext context, {required bool archive}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
if (archive) {
|
||||
bloc.add(StudysetListEventArchiveRequested(studyset.id));
|
||||
} else {
|
||||
bloc.add(StudysetListEventUnarchiveRequested(studyset.id));
|
||||
}
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: archive
|
||||
? l10n.libraryStudysetArchived
|
||||
: l10n.libraryStudysetUnarchived,
|
||||
onUndo: () => bloc.add(
|
||||
archive
|
||||
? StudysetListEventUnarchiveRequested(studyset.id)
|
||||
: StudysetListEventArchiveRequested(studyset.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Back to where the person came from; to the studyset it sat in, or to
|
||||
/// the library, when they came straight here.
|
||||
void _leave(BuildContext context) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(switch (studyset.parentId) {
|
||||
final parentId? => RoutePaths.studyset(parentId),
|
||||
null => RoutePaths.library,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
PopupMenuItem<_Option> item(_Option option, IconData icon, String text) =>
|
||||
PopupMenuItem(
|
||||
value: option,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(text),
|
||||
),
|
||||
);
|
||||
return PopupMenuButton<_Option>(
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_Option.edit => _edit(context),
|
||||
_Option.move => _move(context),
|
||||
_Option.archive => _archive(context, archive: true),
|
||||
_Option.unarchive => _archive(context, archive: false),
|
||||
_Option.delete => deleteStudysetWithUndo(
|
||||
context,
|
||||
studyset,
|
||||
onDeleted: () => _leave(context),
|
||||
),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
||||
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
|
||||
if (studyset.isArchived)
|
||||
item(
|
||||
_Option.unarchive,
|
||||
LucideIcons.archiveRestore,
|
||||
l10n.studysetMenuUnarchive,
|
||||
)
|
||||
else
|
||||
item(_Option.archive, LucideIcons.archive, l10n.studysetMenuArchive),
|
||||
item(_Option.delete, LucideIcons.trash2, l10n.studysetMenuDelete),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,30 +6,43 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/description_editor_screen.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Creates a studyset's content, or edits [initial]: its title, secondary
|
||||
/// title and colour. Completes with the new content once the person saves,
|
||||
/// or with null when they close the sheet; closing it with changes asks
|
||||
/// first.
|
||||
/// title, description and colour. Completes with the new content once the
|
||||
/// person saves, or with null when they close the sheet; closing it with
|
||||
/// changes asks first.
|
||||
///
|
||||
/// What the sheet doesn't show (the description, the licence, the
|
||||
/// languages) keeps its value.
|
||||
/// A studyset [insideAnother] can take the colour of the studyset it is in
|
||||
/// ([inheritedColorName], shown meanwhile), and a new one does at first; at
|
||||
/// the top level a new studyset is cobalt. What the sheet doesn't show (the
|
||||
/// licence, the languages) keeps its value.
|
||||
Future<StudysetContent?> showStudysetSheet(
|
||||
BuildContext context, {
|
||||
StudysetContent? initial,
|
||||
bool insideAnother = false,
|
||||
String? inheritedColorName,
|
||||
}) => showAdaptiveSheet<StudysetContent>(
|
||||
context,
|
||||
// Swiping would discard changes without asking.
|
||||
swipeable: false,
|
||||
builder: (context) => _StudysetSheet(initial: initial),
|
||||
builder: (context) => _StudysetSheet(
|
||||
initial: initial,
|
||||
insideAnother: insideAnother,
|
||||
inheritedColorName: inheritedColorName,
|
||||
),
|
||||
);
|
||||
|
||||
/// A new studyset's colour: the brand's cobalt.
|
||||
/// A new studyset's colour at the top level: the brand's cobalt.
|
||||
const _defaultColor = PaletteColor.cobalt;
|
||||
|
||||
class const _StudysetSheet({required final StudysetContent? initial})
|
||||
extends StatefulWidget {
|
||||
class const _StudysetSheet({
|
||||
required final StudysetContent? initial,
|
||||
required final bool insideAnother,
|
||||
required final String? inheritedColorName,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_StudysetSheet> createState() => _StudysetSheetState();
|
||||
}
|
||||
|
|
@ -39,19 +52,16 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
late final _secondaryTitle = TextEditingController(
|
||||
text: widget.initial?.secondaryTitle,
|
||||
);
|
||||
late var _description = widget.initial?.description ?? RichContent.empty();
|
||||
|
||||
/// The colour the person chose here; null until they choose one. Until
|
||||
/// then a studyset keeps the colour it has, even none, or one only a newer
|
||||
/// app version knows.
|
||||
PaletteColor? _chosenColor;
|
||||
|
||||
/// The colour's name as the content holds it.
|
||||
String? get _colorName =>
|
||||
_chosenColor?.name ??
|
||||
switch (widget.initial) {
|
||||
null => _defaultColor.name,
|
||||
final initial => initial.colorName,
|
||||
};
|
||||
/// The colour's name as the content holds it: none to take the colour of
|
||||
/// the studyset it is in (or, at the top level, the neutral one of
|
||||
/// studysets from before colours). An existing studyset keeps its colour,
|
||||
/// even one only a newer app version knows, until another is picked.
|
||||
late String? _colorName = switch (widget.initial) {
|
||||
final initial? => initial.colorName,
|
||||
null => widget.insideAnother ? null : _defaultColor.name,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -74,7 +84,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
final secondaryTitle = _secondaryTitle.text.trim();
|
||||
return StudysetContent(
|
||||
title: _title.text.trim(),
|
||||
description: initial?.description ?? RichContent.empty(),
|
||||
description: _description,
|
||||
secondaryTitle: secondaryTitle.isEmpty ? null : secondaryTitle,
|
||||
colorName: _colorName,
|
||||
license: initial?.license,
|
||||
|
|
@ -85,13 +95,26 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
bool get _hasChanges {
|
||||
final initial = widget.initial;
|
||||
return initial == null
|
||||
? _title.text.trim().isNotEmpty ||
|
||||
_secondaryTitle.text.trim().isNotEmpty ||
|
||||
_colorName != _defaultColor.name
|
||||
: _content != initial;
|
||||
bool get _hasChanges => switch (widget.initial) {
|
||||
final initial? => _content != initial,
|
||||
null =>
|
||||
_title.text.trim().isNotEmpty ||
|
||||
_secondaryTitle.text.trim().isNotEmpty ||
|
||||
_description != RichContent.empty() ||
|
||||
_colorName != (widget.insideAnother ? null : _defaultColor.name),
|
||||
};
|
||||
|
||||
/// The colour the picker shows: the studyset's own, or while it takes the
|
||||
/// colour of the studyset it is in, that one.
|
||||
PaletteColor get _shownColor =>
|
||||
PaletteColor.byName(_colorName) ??
|
||||
PaletteColor.byName(widget.inheritedColorName) ??
|
||||
_defaultColor;
|
||||
|
||||
Future<void> _editDescription() async {
|
||||
if (await editDescription(context, _description) case final edited?) {
|
||||
setState(() => _description = edited);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -99,6 +122,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final creating = widget.initial == null;
|
||||
final inheriting = widget.insideAnother && _colorName == null;
|
||||
return PopScope<StudysetContent>(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
|
|
@ -119,6 +143,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
title: creating
|
||||
? l10n.studysetSheetCreateTitle
|
||||
: l10n.studysetSheetEditTitle,
|
||||
// A long form whose parts shouldn't make it jump.
|
||||
heightFactor: 0.85,
|
||||
footer: FilledButton(
|
||||
onPressed: _title.text.trim().isEmpty
|
||||
? null
|
||||
|
|
@ -153,16 +179,30 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DescriptionField(
|
||||
description: _description,
|
||||
onTap: _editDescription,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.studysetSheetColor, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
PaletteColorPicker(
|
||||
selected:
|
||||
PaletteColor.byName(_colorName) ??
|
||||
PaletteColor.byName(widget.initial?.colorName) ??
|
||||
_defaultColor,
|
||||
onChanged: (color) => setState(() => _chosenColor = color),
|
||||
selected: _shownColor,
|
||||
// Picking a colour stops taking the other studyset's.
|
||||
onChanged: (color) => setState(() => _colorName = color.name),
|
||||
),
|
||||
if (widget.insideAnother)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(l10n.studysetSheetInheritColor),
|
||||
value: inheriting,
|
||||
// Unticked, it keeps the colour it was showing.
|
||||
onChanged: (inherit) => setState(
|
||||
() => _colorName = inherit ?? false ? null : _shownColor.name,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
|
@ -171,6 +211,41 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The description as a field: its first line, or an invitation to write
|
||||
/// one; tapping it opens the editor.
|
||||
class const _DescriptionField({
|
||||
required final RichContent description,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final text = description.plainText.trim();
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.studysetSheetDescription,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(LucideIcons.pencil, size: 18),
|
||||
),
|
||||
child: Text(
|
||||
text.isEmpty ? l10n.studysetSheetDescriptionAdd : text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: text.isEmpty
|
||||
? theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns what is typed into capitals.
|
||||
class const _UpperCase() extends TextInputFormatter {
|
||||
@override
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue