refactor: one runner for work asked again while it runs, shared editor parts

CoalescingRunner (core/async/) runs work one run at a time and once more
after a run during which it was asked again; watchReads reads a
repository's value so for each listener. They replace nine copies of that
loop: the card, note, path and PDF watches (notes, paths and PDFs read
once per change before, now a burst reads once more), the progress
repository, the material and exam cubits, file uploads, and the sync
service's syncs and pending count. Each now goes on after a failed run
when another was asked for, which some dropped. The card browser keeps
its own loop, as it emits inside the handler that started it.

The card, note and step editors show conflicts through one
ConflictVersions widget, and the note and step editors their saving
through one EditorSaveStatus and SaveStatusLabel.

TagContent, ExamProgress with examCardsToStudy, and PdfChapter move into
their features' models, so widgets no longer import a payload codec, a
service or the PDF engine for a type, and an archive's title is read in
its importer rather than its cubit running the payload codec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-17 13:51:51 +02:00
commit bb7757d7f3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
43 changed files with 757 additions and 650 deletions

View file

@ -98,7 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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. - **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*). - **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `ConflictVersions` (the versions of a conflict each editor shows, with Keep; its save status beside it is `SaveStatusLabel` in `core/editor/`), `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`). - **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`). - **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home. - **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
@ -144,7 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent. - **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows. - **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read. - **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics. What everyone follows (the studysets, the settings, the tags, the progress) is a `LatestValue` (`core/async/`): a screen starting to follow gets the value last read, and only the first has it read, rather than every screen opening having it read again for every screen open; while nobody follows, nothing is kept or read. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `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: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), 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. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced. - **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`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics. What everyone follows (the studysets, the settings, the tags, the progress) is a `LatestValue` (`core/async/`): a screen starting to follow gets the value last read, and only the first has it read, rather than every screen opening having it read again for every screen open; while nobody follows, nothing is kept or read. Work asked for again while it runs runs once more after it, not once each (`CoalescingRunner`, `core/async/`: syncs, uploads, working progress out), and a repository's watch reads so for each listener (`watchReads`); never write that loop by hand again. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `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: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), 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. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes. - **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. 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, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews. - **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. 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, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id. - **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart'; import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
import 'package:halkyon_learn_flutter/src/core/async/coalescing_runner.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart'; import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
/// The files of the person's content (ADR 0004, rule 5): an asset row each, /// The files of the person's content (ADR 0004, rule 5): an asset row each,
@ -51,8 +52,16 @@ class AssetRepository(
/// downloaded once. /// downloaded once.
final _downloads = <String, Future<Uint8List?>>{}; final _downloads = <String, Future<Uint8List?>>{};
Future<void>? _uploading; /// Uploads what waits, one round at a time: a call during a round runs
var _uploadAgain = false; /// one more after it.
late final _uploads = CoalescingRunner(
_uploadAll,
onError: (error, _) =>
log('Uploading files failed', name: 'Assets', error: error),
);
/// Whether it stopped: no round starts any more.
var _stopped = false;
/// Adds [bytes], a file named [fileName] of [mimeType], and returns the id /// Adds [bytes], a file named [fileName] of [mimeType], and returns the id
/// of its asset row: the row of the same file if there is one, else a new /// of its asset row: the row of the same file if there is one, else a new
@ -140,37 +149,29 @@ class AssetRepository(
/// Uploads the files that wait; what fails waits for the next time. One /// Uploads the files that wait; what fails waits for the next time. One
/// run at a time: a call during a run runs once more after it. /// run at a time: a call during a run runs once more after it.
Future<void> uploadPending() { Future<void> uploadPending() => _uploads.run();
if (_uploading case final running?) {
_uploadAgain = true;
return running;
}
return _uploading = _uploadWhileAsked().whenComplete(
() => _uploading = null,
);
}
Future<void> _uploadWhileAsked() async { Future<void> _uploadAll() async {
do { if (_stopped) {
_uploadAgain = false; return;
for (final hash in await _files.pendingUploads()) { }
try { for (final hash in await _files.pendingUploads()) {
if (!await _upload(hash)) { try {
continue; if (!await _upload(hash)) {
} continue;
await _files.clearPending(hash);
} on FileLimitReached catch (error) {
// The others wouldn't fit either: they stay on this device, and
// the next round asks again, once room may have been made.
log('No room for more files', name: 'Assets', error: error);
return;
} on Object catch (error) {
// Without a connection the others fail too: next time.
log('Uploading a file failed', name: 'Assets', error: error);
return;
} }
await _files.clearPending(hash);
} on FileLimitReached catch (error) {
// The others wouldn't fit either: they stay on this device, and
// the next round asks again, once room may have been made.
log('No room for more files', name: 'Assets', error: error);
return;
} on Object catch (error) {
// Without a connection the others fail too: next time.
log('Uploading a file failed', name: 'Assets', error: error);
return;
} }
} while (_uploadAgain); }
} }
/// Uploads the file with [hash]; whether it no longer waits. /// Uploads the file with [hash]; whether it no longer waits.
@ -210,7 +211,7 @@ class AssetRepository(
/// Stops uploading; an upload under way finishes first. /// Stops uploading; an upload under way finishes first.
Future<void> dispose() async { Future<void> dispose() async {
await _triggers.cancel(); await _triggers.cancel();
_uploadAgain = false; _stopped = true;
await _uploading; await _uploads.idle;
} }
} }

View file

@ -0,0 +1,50 @@
import 'dart:async';
/// Runs an action one run at a time (decided 2026-10-17, for what ten
/// hand-written copies did, each catching errors its own way): asked again
/// while a run is under way, it runs once more after it, however often it
/// was asked, so a burst of changes (a sync brings many) costs one more run,
/// not one each, and an older result never overtakes a newer one.
class CoalescingRunner(
/// What a run does.
final Future<void> Function() _action, {
/// Hears of a run that failed; a run asked for meanwhile follows anyway.
required final void Function(Object error, StackTrace stackTrace) _onError,
}) {
/// The runs under way, one asked for meanwhile included; null while none
/// is: a completed future kept from before would run what waits for it in
/// the zone it was made in (AGENTS.md, Testing).
Future<void>? _running;
/// Whether to run once more after the run under way.
var _again = false;
/// Runs now, or once more after the run under way; completes once no run
/// is under way or asked for.
Future<void> run() {
if (_running case final running?) {
_again = true;
return running;
}
final done = Completer<void>();
// Set before the first run starts, so a call during it waits its turn.
_running = done.future;
unawaited(() async {
do {
_again = false;
try {
await _action();
} on Object catch (error, st) {
_onError(error, st);
}
} while (_again);
_running = null;
done.complete();
}());
return done.future;
}
/// Completes once no run is under way or asked for; at once when none is.
Future<void> get idle => _running ?? Future<void>.value();
}

View file

@ -0,0 +1,41 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/async/coalescing_runner.dart';
/// [read]'s value now and after every event of [changes], for each listener
/// on its own: one read at a time, changes during a read making one more
/// after it rather than one each, so an older value never overtakes a newer
/// one. A failed read is logged under [logName] as reading [what] and
/// arrives as an error; the next change reads again.
Stream<T> watchReads<T>(
Stream<void> changes,
Future<T> Function() read, {
required String what,
required String logName,
}) => Stream.multi((listener) {
var cancelled = false;
final reads = CoalescingRunner(
() async {
if (cancelled) {
return;
}
final value = await read();
if (!cancelled) {
listener.add(value);
}
},
onError: (error, stackTrace) {
log('Reading $what failed', name: logName, error: error);
if (!cancelled) {
listener.addError(error, stackTrace);
}
},
);
final subscription = changes.listen((_) => unawaited(reads.run()));
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
unawaited(reads.run());
});

View file

@ -0,0 +1,56 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// How an editor's saving stands: a note's, a learning path step's.
enum EditorSaveStatus() {
/// As stored: nothing changed yet.
clean,
/// Changed, not saved yet.
editing,
/// Being saved.
saving,
/// Saved as it is now.
saved,
/// The last save failed; the next pause tries again.
failed,
}
/// How saving stands, in an editor's app bar: nothing before the first
/// change.
class const SaveStatusLabel({
required final EditorSaveStatus status,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final label = switch (status) {
EditorSaveStatus.clean => null,
EditorSaveStatus.editing => l10n.noteEditing,
EditorSaveStatus.saving => l10n.noteSaving,
EditorSaveStatus.saved => l10n.noteSaved,
EditorSaveStatus.failed => l10n.noteSaveFailed,
};
if (label == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: status == EditorSaveStatus.failed
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
}

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:halkyon_learn_flutter/src/core/async/coalescing_runner.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart'; import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart'; import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart'; import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
@ -42,10 +43,19 @@ class SyncService(
Timer? _debounceTimer; Timer? _debounceTimer;
Timer? _periodic; Timer? _periodic;
AppLifecycleListener? _lifecycle; AppLifecycleListener? _lifecycle;
Future<void>? _running;
bool _runAgain = false;
bool _stopping = false; bool _stopping = false;
/// One sync at a time; a trigger during a sync runs one more afterwards.
late final _syncs = CoalescingRunner(
_syncTriggered,
onError: (error, st) => log(
'Syncing stopped',
name: 'SyncService',
error: error,
stackTrace: st,
),
);
/// The changes made here not on the server yet. /// The changes made here not on the server yet.
var _pending = 0; var _pending = 0;
@ -54,10 +64,18 @@ class SyncService(
/// those changes too. /// those changes too.
late final Future<void> _restoring = _restore(); late final Future<void> _restoring = _restore();
/// Whether a write of the count is under way, and whether another must /// Keeps the count, one write at a time and the latest last: a burst of
/// follow it, the count having changed meanwhile. /// saves writes it twice, not once each.
var _writing = false; late final _writes = CoalescingRunner(
var _writeAgain = false; () async {
// Once stopping, the person's folder may be about to go.
if (pendingChanges case final store? when !_stopping) {
await store.write(_pending);
}
},
onError: (error, _) =>
log('Keeping the pending changes failed', name: 'Sync', error: error),
);
/// Fires whenever a sync brought changes from other devices. /// Fires whenever a sync brought changes from other devices.
Stream<void> get merges => _merges.stream; Stream<void> get merges => _merges.stream;
@ -100,59 +118,53 @@ class SyncService(
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_periodic?.cancel(); _periodic?.cancel();
_lifecycle?.dispose(); _lifecycle?.dispose();
await _running; await _syncs.idle;
await _merges.close(); await _merges.close();
await _synced.close(); await _synced.close();
await _statusChanges.close(); await _statusChanges.close();
} }
// One sync at a time; a trigger during a sync runs one more afterwards.
void _run() { void _run() {
if (!_stopping) {
unawaited(_syncs.run());
}
}
Future<void> _syncTriggered() async {
await _restoring;
if (_stopping) { if (_stopping) {
return; return;
} }
if (_running != null) { _report(
_runAgain = true; SyncStatus(
return; syncing: true,
} lastSyncedAt: _status.lastSyncedAt,
_running = _syncWhileTriggered().whenComplete(() => _running = null); failure: _status.failure,
} pending: _pending,
),
Future<void> _syncWhileTriggered() async { );
await _restoring; // Those saved before the sync starts go with it; those saved while it
do { // runs, perhaps too late for it, wait for the next.
_runAgain = false; final sending = _pending;
try {
await _syncOnce(onMerge: () => _merges.add(null));
_pending -= sending;
_report(SyncStatus(lastSyncedAt: clock.now(), pending: _pending));
_persistPending();
_synced.add(null);
} on Object catch (error, st) {
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
_report( _report(
SyncStatus( SyncStatus(
syncing: true,
lastSyncedAt: _status.lastSyncedAt, lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure, failure: (
at: clock.now(),
appMismatch: error is AppServerMismatchException,
),
pending: _pending, pending: _pending,
), ),
); );
// Those saved before the sync starts go with it; those saved while it }
// runs, perhaps too late for it, wait for the next.
final sending = _pending;
try {
await _syncOnce(onMerge: () => _merges.add(null));
_pending -= sending;
_report(SyncStatus(lastSyncedAt: clock.now(), pending: _pending));
_persistPending();
_synced.add(null);
} on Object catch (error, st) {
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
_report(
SyncStatus(
lastSyncedAt: _status.lastSyncedAt,
failure: (
at: clock.now(),
appMismatch: error is AppServerMismatchException,
),
pending: _pending,
),
);
}
} while (_runAgain && !_stopping);
} }
void _report(SyncStatus status) { void _report(SyncStatus status) {
@ -182,30 +194,9 @@ class SyncService(
_persistPending(); _persistPending();
} }
/// Keeps the count, one write at a time and the latest last: a burst of
/// saves writes it twice, not once each.
void _persistPending() { void _persistPending() {
final store = pendingChanges; if (pendingChanges != null && !_stopping) {
// Once stopping, the person's folder may be about to go. unawaited(_writes.run());
if (store == null || _stopping) {
return;
} }
if (_writing) {
_writeAgain = true;
return;
}
_writing = true;
unawaited(() async {
try {
do {
_writeAgain = false;
await store.write(_pending);
} while (_writeAgain);
} on Object catch (error) {
log('Keeping the pending changes failed', name: 'Sync', error: error);
} finally {
_writing = false;
}
}());
} }
} }

View file

@ -0,0 +1,64 @@
import 'package:material_ui/material_ui.dart';
/// The versions of the fields devices changed differently (ADR 0003), one
/// below the other, each with its field's name and a button keeping it:
/// what the card, note and step editors show of a conflict, below their own
/// words for it. Each editor names its fields (`nameOf`) and shows its
/// kinds of value (`show`).
class const ConflictVersions<F>({
/// The fields still to settle, in order, each with its versions.
required final Map<F, List<Object?>> versions,
required final String Function(F field) nameOf,
required final Widget Function(Object? version) show,
/// Keeps a version of a field, as the button under it asks.
required final void Function(F field, Object? version) onKept,
required final String keepLabel,
/// Whether each version is outlined (on the editor's own background)
/// rather than set on the surface (inside a coloured panel).
final bool outlined = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final MapEntry(key: field, value: fieldVersions)
in versions.entries)
for (final version in fieldVersions)
Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: outlined ? null : theme.colorScheme.surface,
border: outlined
? Border.all(color: theme.colorScheme.outline)
: null,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(nameOf(field), style: theme.textTheme.labelLarge),
show(version),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => onKept(field, version),
child: Text(keepLabel),
),
),
],
),
),
),
),
],
);
}
}

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
@ -6,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart'; import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart'; import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart'; import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
/// Importing an archive: read, then shown with what it holds, then, once /// Importing an archive: read, then shown with what it holds, then, once
/// confirmed, imported, its progress told; done, or failed with why. /// confirmed, imported, its progress told; done, or failed with why.
@ -20,13 +18,9 @@ class ArchiveImportCubit(
Future<void> start(Uint8List bytes) async { Future<void> start(Uint8List bytes) async {
try { try {
final archive = await _reader.read(bytes); final archive = await _reader.read(bytes);
final title = switch (archive.items['studyset.json']?['payload']) { emit(
final Map<String, Object?> payload => decodeStudysetPayload( ArchiveImportStateReady(archive: archive, title: archiveTitle(archive)),
ByteData.sublistView(utf8.encode(jsonEncode(payload))), );
).title,
_ => '',
};
emit(ArchiveImportStateReady(archive: archive, title: title));
} on ArchiveUnreadable catch (error) { } on ArchiveUnreadable catch (error) {
emit(ArchiveImportStateFailed(error.problem)); emit(ArchiveImportStateFailed(error.problem));
} on Object { } on Object {

View file

@ -11,9 +11,20 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// The title of the studyset at the top of [archive]; empty when it has
/// none that this app can read.
String archiveTitle(ImportArchive archive) =>
switch (archive.items['studyset.json']?['payload']) {
final Map<String, Object?> payload => decodeStudysetPayload(
ByteData.sublistView(utf8.encode(jsonEncode(payload))),
).title,
_ => '',
};
/// What an import brought: the top studyset made, how many cards and /// What an import brought: the top studyset made, how many cards and
/// reviews, and how many cards were left out, naming dictionary entries the /// reviews, and how many cards were left out, naming dictionary entries the
/// instance doesn't hold. /// instance doesn't hold.

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart'; import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -70,46 +71,12 @@ class CardRepository(final CardGateway _gateway) {
/// The cards of [studysetId] in order, now and after every change, local or /// The cards of [studysetId] in order, now and after every change, local or
/// synced. A read that fails arrives as an error; the next change reads /// synced. A read that fails arrives as an error; the next change reads
/// again. /// again.
Stream<List<Card>> watch(String studysetId) => Stream.multi((listener) { Stream<List<Card>> watch(String studysetId) => watchReads(
var cancelled = false; _changes.stream,
var reading = false; () => cardsOf(studysetId),
var again = false; what: 'cards',
// One read at a time, so an older list never overtakes a newer one; logName: 'Cards',
// changes while one runs make one more read after it, not one each (a );
// level of words added, a sync bringing many).
Future<void> read() async {
if (reading) {
again = true;
return;
}
reading = true;
do {
again = false;
if (cancelled) {
break;
}
try {
final cards = await cardsOf(studysetId);
if (!cancelled) {
listener.add(cards);
}
} on Object catch (error, st) {
log('Reading cards failed', name: 'Cards', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
} while (again);
reading = false;
}
final subscription = _changes.stream.listen((_) => unawaited(read()));
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
unawaited(read());
});
/// The cards of [studysetId] on this device right now, in order, read /// The cards of [studysetId] on this device right now, in order, read
/// once; [watch] follows every change. /// once; [watch] follows every change.

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart'; import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -707,43 +708,19 @@ class const _Conflicts({required final CardDraft draft})
color: theme.colorScheme.error, color: theme.colorScheme.error,
), ),
), ),
for (final field in draft.unresolved) ConflictVersions<CardField>(
for (final version in draft.conflicts[field]!) versions: {
Padding( for (final field in draft.unresolved)
padding: const EdgeInsets.only(top: 8), field: draft.conflicts[field]!,
child: DecoratedBox( },
decoration: BoxDecoration( nameOf: (field) => _fieldName(l10n, field),
border: Border.all(color: theme.colorScheme.outline), show: (version) => _Version(version: version),
borderRadius: BorderRadius.circular(8), onKept: (field, version) => context.read<CardComposerBloc>().add(
), CardComposerEventVersionChosen(draft.id, field, version),
child: Padding( ),
padding: const EdgeInsets.all(8), keepLabel: l10n.composerKeepThis,
child: Column( outlined: true,
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [
Text(
_fieldName(l10n, field),
style: theme.textTheme.labelLarge,
),
_Version(version: version),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => context.read<CardComposerBloc>().add(
CardComposerEventVersionChosen(
draft.id,
field,
version,
),
),
child: Text(l10n.composerKeepThis),
),
),
],
),
),
),
),
], ],
); );
} }

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart'; import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
@ -98,7 +99,7 @@ class StepEditorBloc(
conflicts: step.conflicts, conflicts: step.conflicts,
); );
} }
if (state.isDirty || state.status == StepSaveStatus.saving) { if (state.isDirty || state.status == EditorSaveStatus.saving) {
return state.copyWith(saved: step.content); return state.copyWith(saved: step.content);
} }
_base = step.heads; _base = step.heads;
@ -124,10 +125,10 @@ class StepEditorBloc(
emit( emit(
changed.copyWith( changed.copyWith(
status: switch (state.status) { status: switch (state.status) {
StepSaveStatus.saving => null, EditorSaveStatus.saving => null,
_ when changed.isDirty => StepSaveStatus.editing, _ when changed.isDirty => EditorSaveStatus.editing,
_ when _savedHere => StepSaveStatus.saved, _ when _savedHere => EditorSaveStatus.saved,
_ => StepSaveStatus.clean, _ => EditorSaveStatus.clean,
}, },
), ),
); );
@ -157,12 +158,12 @@ class StepEditorBloc(
Future<void> _save(Emitter<StepEditorState> emit) async { Future<void> _save(Emitter<StepEditorState> emit) async {
_idle?.cancel(); _idle?.cancel();
if (!state.canSave || state.status == StepSaveStatus.saving) { if (!state.canSave || state.status == EditorSaveStatus.saving) {
return; return;
} }
final content = state.content.normalized; final content = state.content.normalized;
final settling = state.conflicts.isNotEmpty; final settling = state.conflicts.isNotEmpty;
emit(state.copyWith(status: StepSaveStatus.saving)); emit(state.copyWith(status: EditorSaveStatus.saving));
try { try {
final written = await _write(content); final written = await _write(content);
_savedHere = _savedHere || written != null; _savedHere = _savedHere || written != null;
@ -172,13 +173,13 @@ class StepEditorBloc(
conflicts: settling ? const {} : null, conflicts: settling ? const {} : null,
resolved: settling ? const {} : null, resolved: settling ? const {} : null,
status: state.content.normalized == content status: state.content.normalized == content
? StepSaveStatus.saved ? EditorSaveStatus.saved
: StepSaveStatus.editing, : EditorSaveStatus.editing,
), ),
); );
} on Object catch (error) { } on Object catch (error) {
log('Saving a step failed', name: 'Paths', error: error); log('Saving a step failed', name: 'Paths', error: error);
emit(state.copyWith(status: StepSaveStatus.failed)); emit(state.copyWith(status: EditorSaveStatus.failed));
} }
} }
@ -193,24 +194,6 @@ class StepEditorBloc(
}); });
} }
/// How the step's saving stands.
enum StepSaveStatus() {
/// As stored: nothing changed yet.
clean,
/// Changed, not saved yet.
editing,
/// Being saved.
saving,
/// Saved as it is now.
saved,
/// The last save failed; the next pause tries again.
failed,
}
/// The step as its editor holds it. /// The step as its editor holds it.
final class const StepEditorState({ final class const StepEditorState({
/// What kind of step it is. /// What kind of step it is.
@ -226,7 +209,7 @@ final class const StepEditorState({
final bool loaded = false, final bool loaded = false,
/// How saving stands. /// How saving stands.
final StepSaveStatus status = StepSaveStatus.clean, final EditorSaveStatus status = EditorSaveStatus.clean,
/// The fields devices changed differently, with every version. /// The fields devices changed differently, with every version.
final Map<StepField, List<Object?>> conflicts = const {}, final Map<StepField, List<Object?>> conflicts = const {},
@ -258,7 +241,7 @@ final class const StepEditorState({
StepContent? content, StepContent? content,
StepContent? saved, StepContent? saved,
bool? loaded, bool? loaded,
StepSaveStatus? status, EditorSaveStatus? status,
Map<StepField, List<Object?>>? conflicts, Map<StepField, List<Object?>>? conflicts,
Set<StepField>? resolved, Set<StepField>? resolved,
int? revision, int? revision,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart'; import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
@ -319,32 +320,7 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
/// list never overtakes a newer one. A failed read arrives as an error; /// list never overtakes a newer one. A failed read arrives as an error;
/// the next change reads again. /// the next change reads again.
Stream<T> _watch<T>(Future<T> Function() read, String what) => Stream<T> _watch<T>(Future<T> Function() read, String what) =>
Stream.multi((listener) { watchReads(_changes.stream, read, what: what, logName: 'Paths');
var cancelled = false;
var reading = Future<void>.value();
void next() => reading = reading.then((_) async {
if (cancelled) {
return;
}
try {
final value = await read();
if (!cancelled) {
listener.add(value);
}
} on Object catch (error, st) {
log('Reading $what failed', name: 'Paths', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
});
final subscription = _changes.stream.listen((_) => next());
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
next();
});
LearningPath? _decodePath(PathRow row) { LearningPath? _decodePath(PathRow row) {
try { try {

View file

@ -9,8 +9,10 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart'; import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart'; import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
@ -67,7 +69,7 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
: l10n.stepEditTitle, : l10n.stepEditTitle,
), ),
actions: [ actions: [
_SaveStatus(status: state.status), SaveStatusLabel(status: state.status),
if (fromRelease(state.content.published)) if (fromRelease(state.content.published))
IconButton( IconButton(
tooltip: l10n.suggestToAuthor, tooltip: l10n.suggestToAuthor,
@ -577,43 +579,18 @@ class const _Conflicts({required final StepEditorState state})
color: theme.colorScheme.onErrorContainer, color: theme.colorScheme.onErrorContainer,
), ),
), ),
for (final field in state.unresolved) ConflictVersions<StepField>(
for (final version in state.conflicts[field]!) versions: {
Padding( for (final field in state.unresolved)
padding: const EdgeInsets.only(top: 8), field: state.conflicts[field]!,
child: DecoratedBox( },
decoration: BoxDecoration( nameOf: (field) => _fieldName(l10n, field),
color: theme.colorScheme.surface, show: (version) => _Version(version: version),
borderRadius: BorderRadius.circular(8), onKept: (field, version) => context.read<StepEditorBloc>().add(
), StepEditorEventVersionChosen(field, version),
child: Padding( ),
padding: const EdgeInsets.all(8), keepLabel: l10n.noteKeepThis,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_fieldName(l10n, field),
style: theme.textTheme.labelLarge,
),
_Version(version: version),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
context.read<StepEditorBloc>().add(
StepEditorEventVersionChosen(
field,
version,
),
),
child: Text(l10n.noteKeepThis),
),
),
],
),
),
),
),
], ],
), ),
), ),
@ -676,36 +653,3 @@ class const _Version({required final Object? version}) extends StatelessWidget {
_ => Text('${version ?? ''}'), _ => Text('${version ?? ''}'),
}; };
} }
/// How saving stands, in the app bar: nothing before the first change.
class const _SaveStatus({required final StepSaveStatus status})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final label = switch (status) {
StepSaveStatus.clean => null,
StepSaveStatus.editing => l10n.noteEditing,
StepSaveStatus.saving => l10n.noteSaving,
StepSaveStatus.saved => l10n.noteSaved,
StepSaveStatus.failed => l10n.noteSaveFailed,
};
if (label == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: status == StepSaveStatus.failed
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart'; import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
@ -129,7 +130,7 @@ class NoteEditorBloc(
return state; return state;
} }
_seen = true; _seen = true;
if (state.isDirty || state.status == NoteSaveStatus.saving) { if (state.isDirty || state.status == EditorSaveStatus.saving) {
return state.copyWith(loaded: true, saved: note.content); return state.copyWith(loaded: true, saved: note.content);
} }
_base = note.heads; _base = note.heads;
@ -170,11 +171,11 @@ class NoteEditorBloc(
emit( emit(
changed.copyWith( changed.copyWith(
status: switch (state.status) { status: switch (state.status) {
NoteSaveStatus.saving => null, EditorSaveStatus.saving => null,
_ when changed.isDirty => NoteSaveStatus.editing, _ when changed.isDirty => EditorSaveStatus.editing,
// Back to what is stored, as a space at a title's end is. // Back to what is stored, as a space at a title's end is.
_ when _savedHere => NoteSaveStatus.saved, _ when _savedHere => EditorSaveStatus.saved,
_ => NoteSaveStatus.clean, _ => EditorSaveStatus.clean,
}, },
), ),
); );
@ -219,12 +220,12 @@ class NoteEditorBloc(
Future<void> _save(Emitter<NoteEditorState> emit) async { Future<void> _save(Emitter<NoteEditorState> emit) async {
_idle?.cancel(); _idle?.cancel();
if (!state.canSave || state.status == NoteSaveStatus.saving) { if (!state.canSave || state.status == EditorSaveStatus.saving) {
return; return;
} }
final content = state.content.normalized; final content = state.content.normalized;
final settling = state.conflicts.isNotEmpty; final settling = state.conflicts.isNotEmpty;
emit(state.copyWith(status: NoteSaveStatus.saving)); emit(state.copyWith(status: EditorSaveStatus.saving));
try { try {
final written = await _write(content); final written = await _write(content);
_savedHere = _savedHere || written != null; _savedHere = _savedHere || written != null;
@ -236,13 +237,13 @@ class NoteEditorBloc(
resolved: settling ? const {} : null, resolved: settling ? const {} : null,
// Changed again while saving: the pause after it saves that. // Changed again while saving: the pause after it saves that.
status: state.content.normalized == content status: state.content.normalized == content
? NoteSaveStatus.saved ? EditorSaveStatus.saved
: NoteSaveStatus.editing, : EditorSaveStatus.editing,
), ),
); );
} on Object catch (error) { } on Object catch (error) {
log('Saving a note failed', name: 'Notes', error: error); log('Saving a note failed', name: 'Notes', error: error);
emit(state.copyWith(status: NoteSaveStatus.failed)); emit(state.copyWith(status: EditorSaveStatus.failed));
} }
} }
@ -270,24 +271,6 @@ class NoteEditorBloc(
bool _sameSet(Set<String> a, Set<String> b) => bool _sameSet(Set<String> a, Set<String> b) =>
a.length == b.length && a.containsAll(b); a.length == b.length && a.containsAll(b);
/// How the note's saving stands.
enum NoteSaveStatus() {
/// As stored: nothing changed yet.
clean,
/// Changed, not saved yet.
editing,
/// Being saved.
saving,
/// Saved as it is now.
saved,
/// The last save failed; the next pause tries again.
failed,
}
/// The note as the editor holds it. /// The note as the editor holds it.
final class const NoteEditorState({ final class const NoteEditorState({
/// The content as the person has it now. /// The content as the person has it now.
@ -303,7 +286,7 @@ final class const NoteEditorState({
final bool loaded = false, final bool loaded = false,
/// How saving stands. /// How saving stands.
final NoteSaveStatus status = NoteSaveStatus.clean, final EditorSaveStatus status = EditorSaveStatus.clean,
/// The fields devices changed differently, with every version, oldest /// The fields devices changed differently, with every version, oldest
/// first. /// first.
@ -350,7 +333,7 @@ final class const NoteEditorState({
NoteContent? saved, NoteContent? saved,
String? noteId, String? noteId,
bool? loaded, bool? loaded,
NoteSaveStatus? status, EditorSaveStatus? status,
Map<NoteField, List<Object?>>? conflicts, Map<NoteField, List<Object?>>? conflicts,
Set<NoteField>? resolved, Set<NoteField>? resolved,
Set<String>? tagIds, Set<String>? tagIds,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart'; import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
@ -29,33 +30,12 @@ class NoteRepository(final NoteGateway _gateway) {
/// The notes of [studysetId] in order, now and after every change, local /// The notes of [studysetId] in order, now and after every change, local
/// or synced. A read that fails arrives as an error; the next change reads /// or synced. A read that fails arrives as an error; the next change reads
/// again. /// again.
Stream<List<Note>> watch(String studysetId) => Stream.multi((listener) { Stream<List<Note>> watch(String studysetId) => watchReads(
var cancelled = false; _changes.stream,
var reading = Future<void>.value(); () => notesOf(studysetId),
// One read after another, so an older list never overtakes a newer one. what: 'notes',
void read() => reading = reading.then((_) async { logName: 'Notes',
if (cancelled) { );
return;
}
try {
final notes = await notesOf(studysetId);
if (!cancelled) {
listener.add(notes);
}
} on Object catch (error, st) {
log('Reading notes failed', name: 'Notes', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
});
final subscription = _changes.stream.listen((_) => read());
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
read();
});
/// The notes of [studysetId] on this device right now, in order, read /// The notes of [studysetId] on this device right now, in order, read
/// once; [watch] follows every change. /// once; [watch] follows every change.

View file

@ -5,8 +5,10 @@ import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart'; import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart'; import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
@ -72,7 +74,7 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context), systemOverlayStyle: TopGradient.overlayStyle(context),
actions: [ actions: [
_SaveStatus(status: state.status), SaveStatusLabel(status: state.status),
if (fromRelease(state.content.published)) if (fromRelease(state.content.published))
IconButton( IconButton(
tooltip: AppLocalizations.of(context).suggestToAuthor, tooltip: AppLocalizations.of(context).suggestToAuthor,
@ -252,95 +254,35 @@ class const _Conflicts({required final NoteEditorState state})
color: theme.colorScheme.onErrorContainer, color: theme.colorScheme.onErrorContainer,
), ),
), ),
for (final field in state.unresolved) ConflictVersions<NoteField>(
for (final version in state.conflicts[field]!) versions: {
Padding( for (final field in state.unresolved)
padding: const EdgeInsets.only(top: 8), field: state.conflicts[field]!,
child: DecoratedBox( },
decoration: BoxDecoration( nameOf: (field) => switch (field) {
color: theme.colorScheme.surface, NoteField.title => l10n.noteFieldTitle,
borderRadius: BorderRadius.circular(8), NoteField.body => l10n.noteFieldBody,
), NoteField.source => l10n.pdfPageField,
child: Padding( NoteField.published => l10n.publishedField,
padding: const EdgeInsets.all(8), },
child: Column( show: (version) => switch (version) {
crossAxisAlignment: CrossAxisAlignment.stretch, final RichContent body => RichContentView(content: body),
children: [ (pdfId: _, page: final int page) => Text(
Text( l10n.pdfPageShort(page),
switch (field) {
NoteField.title => l10n.noteFieldTitle,
NoteField.body => l10n.noteFieldBody,
NoteField.source => l10n.pdfPageField,
NoteField.published => l10n.publishedField,
},
style: theme.textTheme.labelLarge,
),
switch (version) {
final RichContent body => RichContentView(
content: body,
),
(pdfId: _, page: final int page) => Text(
l10n.pdfPageShort(page),
),
PublishedRef(:final version) => Text(
l10n.publishedVersion(version),
),
_ => Text('${version ?? ''}'),
},
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
context.read<NoteEditorBloc>().add(
NoteEditorEventVersionChosen(
field,
version,
),
),
child: Text(l10n.noteKeepThis),
),
),
],
),
),
),
), ),
PublishedRef(:final version) => Text(
l10n.publishedVersion(version),
),
_ => Text('${version ?? ''}'),
},
onKept: (field, version) => context.read<NoteEditorBloc>().add(
NoteEditorEventVersionChosen(field, version),
),
keepLabel: l10n.noteKeepThis,
),
], ],
), ),
), ),
); );
} }
} }
/// How saving stands, in the app bar: nothing before the first change.
class const _SaveStatus({required final NoteSaveStatus status})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final label = switch (status) {
NoteSaveStatus.clean => null,
NoteSaveStatus.editing => l10n.noteEditing,
NoteSaveStatus.saving => l10n.noteSaving,
NoteSaveStatus.saved => l10n.noteSaved,
NoteSaveStatus.failed => l10n.noteSaveFailed,
};
if (label == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: status == NoteSaveStatus.failed
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
/// A PDF being read: its file found (downloaded if this device lacks it) /// A PDF being read: its file found (downloaded if this device lacks it)

View file

@ -1,11 +1,8 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A chapter of a PDF, from its outline: its title and the page it starts
/// on, from 1.
typedef PdfChapter = ({String title, int page});
/// The renderer PDFs are read with: pdfrx (PDFium) in the app /// The renderer PDFs are read with: pdfrx (PDFium) in the app
/// (`PdfrxEngine`), a fake in tests, where PDFium can't be loaded. /// (`PdfrxEngine`), a fake in tests, where PDFium can't be loaded.
abstract interface class PdfEngine() { abstract interface class PdfEngine() {

View file

@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart'; import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
@ -328,32 +329,7 @@ class PdfSourceRepository(
/// [read] now and after every change, one read at a time and in order. /// [read] now and after every change, one read at a time and in order.
Stream<T> _watch<T>(Future<T> Function() read, String what) => Stream<T> _watch<T>(Future<T> Function() read, String what) =>
Stream.multi((listener) { watchReads(_changes.stream, read, what: what, logName: 'PDFs');
var cancelled = false;
var reading = Future<void>.value();
void next() => reading = reading.then((_) async {
if (cancelled) {
return;
}
try {
final value = await read();
if (!cancelled) {
listener.add(value);
}
} on Object catch (error, st) {
log('Reading $what failed', name: 'PDFs', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
});
final subscription = _changes.stream.listen((_) => next());
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
next();
});
Future<void> _update( Future<void> _update(
String id, String id,

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