feat(app): undo every step of a study session, deletions held until it ends

The header's Undo takes back ratings, burials and deletions, last first,
across the session's rounds and from the end of a round, and shows the
card again. A card deleted during a session is held (DelayedDeletions.hold)
until the session ends, then goes after the usual time to undo, so the
message offering Undo still works; if the app is killed first, the card
stays. Undo on a burial's or deletion's message takes back that step alone.

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-07 22:30:44 +02:00
commit 5a9441a717
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 451 additions and 143 deletions

View file

@ -138,14 +138,14 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read. - **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced. - **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes. - **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back the last rating of a round deletes its row. Besides ratings it holds a fresh start (`reset`) and a burial (`bury`: the card set aside until the next local day, out of every queue and count; Unbury deletes it), neither ever counting as studying; older app versions skip a mode they don't know. 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), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews. - **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`) and a burial (`bury`: the card set aside until the next local day, out of every queue and count; Unbury deletes it), neither ever counting as studying; older app versions skip a mode they don't know. 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), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id. - **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync. - **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people. - **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes. - **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`). - **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`. - **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied). - **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied).
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`). - **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one. - **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.

View file

@ -487,7 +487,7 @@
"studySettingsTagFilterPausedForBacklog": "Diese Sitzung arbeitet deinen Wiederholungsrückstand über alle Tags hinweg ab, deshalb schränken diese Tags sie nicht ein. Ab der nächsten Sitzung greifen sie wieder.", "studySettingsTagFilterPausedForBacklog": "Diese Sitzung arbeitet deinen Wiederholungsrückstand über alle Tags hinweg ab, deshalb schränken diese Tags sie nicht ein. Ab der nächsten Sitzung greifen sie wieder.",
"studyBuryCard": "Bis morgen zurückstellen", "studyBuryCard": "Bis morgen zurückstellen",
"studyCardBuried": "Karte bis morgen zurückgestellt", "studyCardBuried": "Karte bis morgen zurückgestellt",
"studyUndoLastRating": "Letzte Bewertung rückgängig machen", "studyUndo": "Rückgängig machen",
"studyLockScreen": "Bildschirm sperren", "studyLockScreen": "Bildschirm sperren",
"studyUnlock": "Entsperren", "studyUnlock": "Entsperren",
"studyTapToUnlock": "Zum Entsperren tippen", "studyTapToUnlock": "Zum Entsperren tippen",

View file

@ -537,7 +537,7 @@
"studySettingsTagFilterPausedForBacklog": "This session is clearing your review backlog across every tag, so these tags aren't narrowing it. They apply again to your next session.", "studySettingsTagFilterPausedForBacklog": "This session is clearing your review backlog across every tag, so these tags aren't narrowing it. They apply again to your next session.",
"studyBuryCard": "Bury until tomorrow", "studyBuryCard": "Bury until tomorrow",
"studyCardBuried": "Card buried until tomorrow", "studyCardBuried": "Card buried until tomorrow",
"studyUndoLastRating": "Undo last rating", "studyUndo": "Undo",
"studyLockScreen": "Lock screen", "studyLockScreen": "Lock screen",
"studyUnlock": "Unlock", "studyUnlock": "Unlock",
"studyTapToUnlock": "Tap to unlock", "studyTapToUnlock": "Tap to unlock",

View file

@ -2938,11 +2938,11 @@ abstract class AppLocalizations {
/// **'Card buried until tomorrow'** /// **'Card buried until tomorrow'**
String get studyCardBuried; String get studyCardBuried;
/// No description provided for @studyUndoLastRating. /// No description provided for @studyUndo.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Undo last rating'** /// **'Undo'**
String get studyUndoLastRating; String get studyUndo;
/// No description provided for @studyLockScreen. /// No description provided for @studyLockScreen.
/// ///

View file

@ -1711,7 +1711,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get studyCardBuried => 'Karte bis morgen zurückgestellt'; String get studyCardBuried => 'Karte bis morgen zurückgestellt';
@override @override
String get studyUndoLastRating => 'Letzte Bewertung rückgängig machen'; String get studyUndo => 'Rückgängig machen';
@override @override
String get studyLockScreen => 'Bildschirm sperren'; String get studyLockScreen => 'Bildschirm sperren';

View file

@ -1700,7 +1700,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get studyCardBuried => 'Card buried until tomorrow'; String get studyCardBuried => 'Card buried until tomorrow';
@override @override
String get studyUndoLastRating => 'Undo last rating'; String get studyUndo => 'Undo';
@override @override
String get studyLockScreen => 'Lock screen'; String get studyLockScreen => 'Lock screen';

View file

@ -3,8 +3,8 @@ import 'dart:developer';
/// Deletions that can be undone for a moment (decided 2026-10-01): what is /// Deletions that can be undone for a moment (decided 2026-10-01): what is
/// deleted leaves every list at once ([pending]), and only once the time to /// deleted leaves every list at once ([pending]), and only once the time to
/// undo is over does [_deleteNow] run, since blanking can't be undone (ADR /// undo is over (for a deletion held, counted from when [delete] lets it go)
/// 0002, rule 2). /// does [_deleteNow] run, since blanking can't be undone (ADR 0002, rule 2).
/// ///
/// It belongs to the person's data, so a deletion outlives the screen it /// It belongs to the person's data, so a deletion outlives the screen it
/// started on, and it closes with the person's data: a deletion still /// started on, and it closes with the person's data: a deletion still
@ -20,8 +20,9 @@ class DelayedDeletions(
/// offering it shows, and a moment for a late tap. /// offering it shows, and a moment for a late tap.
final Duration _undoWindow = const Duration(seconds: 4), final Duration _undoWindow = const Duration(seconds: 4),
}) { }) {
/// The deletions that can still be undone, by the item's id. /// The deletions that can still be undone, by the item's id: with the
final _waiting = <String, Timer>{}; /// timer that starts it, or none while it is held.
final _waiting = <String, Timer?>{};
/// The deletions under way, too late to undo. /// The deletions under way, too late to undo.
final _deleting = <String, Future<void>>{}; final _deleting = <String, Future<void>>{};
@ -48,10 +49,23 @@ class DelayedDeletions(
_changed(); _changed();
} }
/// Takes the item [id] out of every list as [delete] does, but deletes it
/// only once [delete] is called for it, however long that takes: e.g. at
/// the end of a study session, which can undo it until then. Closing drops
/// it as any deletion still waiting, and the item stays.
void hold(String id) {
if (_deleting.containsKey(id)) {
return;
}
_waiting[id]?.cancel();
_waiting[id] = null;
_changed();
}
/// Keeps the item [id] after all, while its deletion still waits. /// Keeps the item [id] after all, while its deletion still waits.
void undo(String id) { void undo(String id) {
if (_waiting.remove(id) case final timer?) { if (_waiting.containsKey(id)) {
timer.cancel(); _waiting.remove(id)?.cancel();
_changed(); _changed();
} }
} }
@ -59,7 +73,7 @@ class DelayedDeletions(
/// Drops the deletions still waiting, and lets those under way finish. /// Drops the deletions still waiting, and lets those under way finish.
Future<void> dispose() async { Future<void> dispose() async {
for (final timer in _waiting.values) { for (final timer in _waiting.values) {
timer.cancel(); timer?.cancel();
} }
_waiting.clear(); _waiting.clear();
await Future.wait(_deleting.values); await Future.wait(_deleting.values);

View file

@ -74,11 +74,17 @@ class StudySessionBloc(
on<StudySessionEventLockToggled>(_onLockToggled); on<StudySessionEventLockToggled>(_onLockToggled);
on<StudySessionEventUndone>(_onUndone); on<StudySessionEventUndone>(_onUndone);
on<StudySessionEventBuried>(_onBuried); on<StudySessionEventBuried>(_onBuried);
on<StudySessionEventUnburied>(_onUnburied); on<StudySessionEventUnburied>(
(event, emit) =>
_undoStepOn(emit, event.cardId, (step) => step is _Buried),
);
on<StudySessionEventCardEdited>(_onCardEdited); on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventReported>(_onReported); on<StudySessionEventReported>(_onReported);
on<StudySessionEventCardDeleted>(_onCardDeleted); on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventDeleteUndone>(_onDeleteUndone); on<StudySessionEventDeleteUndone>(
(event, emit) =>
_undoStepOn(emit, event.cardId, (step) => step is _Deleted),
);
on<StudySessionEventBackFirstToggled>((event, emit) async { on<StudySessionEventBackFirstToggled>((event, emit) async {
// A session focused on a side turns for itself; the settings stay as // A session focused on a side turns for itself; the settings stay as
// they are. // they are.
@ -116,11 +122,6 @@ class StudySessionBloc(
/// What wrong answers are drawn from, while words are quizzed. /// What wrong answers are drawn from, while words are quizzed.
var _pools = const QuizPools(meanings: [], readings: [], expressions: []); var _pools = const QuizPools(meanings: [], readings: [], expressions: []);
/// The card set aside last in this round, and when, to undo it.
({Card card, DateTime at, int index})? _lastBuried;
/// The card deleted last in this round, and where it was, to undo it.
({Card card, int index})? _lastDeleted;
final _ratings = <String, Rating>{}; final _ratings = <String, Rating>{};
final _graduated = <String>{}; final _graduated = <String>{};
var _cramIndex = 0; var _cramIndex = 0;
@ -137,8 +138,15 @@ class StudySessionBloc(
/// reviews of others are due. /// reviews of others are due.
var _learnAnyway = false; var _learnAnyway = false;
/// The ratings of the round that can be taken back, the last on top. /// The steps of the session that can be taken back, the last on top:
final _undoable = <_Undoable>[]; /// every rating, burial and deletion since the screen opened. A card
/// deleted waits until the session ends ([close]).
final _steps = <_Step>[];
/// Counts the sessions and rounds begun, so that a step knows whether it
/// was taken in the one under way.
var _sessionNumber = 0;
var _roundNumber = 0;
// How the session goes, over all its rounds: when it began, every rating // How the session goes, over all its rounds: when it began, every rating
// given, and the cards done. // given, and the cards done.
@ -158,6 +166,7 @@ class StudySessionBloc(
/// Begins the session anew: from now, with nothing rated yet. /// Begins the session anew: from now, with nothing rated yet.
Future<void> _startSession(Emitter<StudySessionState> emit) { Future<void> _startSession(Emitter<StudySessionState> emit) {
_sessionNumber++;
_startedAt = clock.now().toUtc(); _startedAt = clock.now().toUtc();
_lockedAt = null; _lockedAt = null;
_clearingBacklog = false; _clearingBacklog = false;
@ -183,11 +192,9 @@ class StudySessionBloc(
if (_focus case final focus?) { if (_focus case final focus?) {
_options = _options.copyWith(backFirst: focus.backFirst); _options = _options.copyWith(backFirst: focus.backFirst);
} }
_roundNumber++;
_ratings.clear(); _ratings.clear();
_graduated.clear(); _graduated.clear();
_undoable.clear();
_lastBuried = null;
_lastDeleted = null;
_cramIndex = 0; _cramIndex = 0;
final groups = await _groups(filtered: !_clearingBacklog); final groups = await _groups(filtered: !_clearingBacklog);
_cardsById = { _cardsById = {
@ -210,7 +217,12 @@ class StudySessionBloc(
]; ];
_round = _ordered(all).take(_options.cardsPerRound).toList(); _round = _ordered(all).take(_options.cardsPerRound).toList();
if (_round.isEmpty) { if (_round.isEmpty) {
emit(StudySessionStateNothing(backFirst: _options.backFirst)); emit(
StudySessionStateNothing(
backFirst: _options.backFirst,
canUndo: _steps.isNotEmpty,
),
);
return; return;
} }
_crammed.addAll(_round); _crammed.addAll(_round);
@ -259,6 +271,7 @@ class StudySessionBloc(
StudySessionStateNothing( StudySessionStateNothing(
next: upcoming.firstOrNull, next: upcoming.firstOrNull,
backFirst: _options.backFirst, backFirst: _options.backFirst,
canUndo: _steps.isNotEmpty,
), ),
); );
return; return;
@ -471,7 +484,7 @@ class StudySessionBloc(
// last, and skipping is always there. // last, and skipping is always there.
canGoBack: _options.mode == LearnMode.cram && _round.length > 1, canGoBack: _options.mode == LearnMode.cram && _round.length > 1,
canGoForward: _options.mode == LearnMode.cram, canGoForward: _options.mode == LearnMode.cram,
canUndo: _undoable.isNotEmpty, canUndo: _steps.isNotEmpty,
tagFilterCount: _tagFilterCount, tagFilterCount: _tagFilterCount,
backFirst: _options.backFirst, backFirst: _options.backFirst,
locked: _lockedAt != null, locked: _lockedAt != null,
@ -517,6 +530,7 @@ class StudySessionBloc(
: _given.map(_credit).reduce((a, b) => a + b) / _given.length, : _given.map(_credit).reduce((a, b) => a + b) / _given.length,
duration: now.difference(_startedAt), duration: now.difference(_startedAt),
backFirst: _options.backFirst, backFirst: _options.backFirst,
canUndo: _steps.isNotEmpty,
); );
} }
@ -566,18 +580,7 @@ class StudySessionBloc(
emit(showing.withRatingFailed()); emit(showing.withRatingFailed());
return; return;
} }
_undoable.add( _steps.add(_Rated(_standOf(card), at: now, mode: _modeOf(card)));
_Undoable(
showing: showing,
rating: (cardId: card.id, at: now, mode: _modeOf(card)),
round: [..._round],
ratings: {..._ratings},
graduated: {..._graduated},
done: {..._done},
given: _given.length,
cramIndex: _cramIndex,
),
);
_ratings[card.id] = event.rating; _ratings[card.id] = event.rating;
_given.add(event.rating); _given.add(event.rating);
if (_options.mode == LearnMode.cram) { if (_options.mode == LearnMode.cram) {
@ -658,99 +661,163 @@ class StudySessionBloc(
) async { ) async {
if (state case final StudySessionStateShowing showing) { if (state case final StudySessionStateShowing showing) {
final card = showing.card; final card = showing.card;
// Deleted for good once the time to undo it is over. final before = _standOf(card);
_deletions.delete(card.id); // Deleted for good once the session ends; until then it can be undone.
_lastDeleted = (card: card, index: _round.indexOf(card.id)); _deletions.hold(card.id);
_steps.add(_Deleted(before));
await _leave(emit, card.id); await _leave(emit, card.id);
} }
} }
void _onDeleteUndone(
StudySessionEventDeleteUndone event,
Emitter<StudySessionState> emit,
) {
final deleted = _lastDeleted;
_lastDeleted = null;
if (deleted == null) {
return;
}
final (:card, :index) = deleted;
_deletions.undo(card.id);
// Back where it was in the round under way; the card on screen stays.
if (state case final StudySessionStateShowing showing) {
_cardsById[card.id] = card;
_round.insert(index.clamp(0, _round.length), card.id);
_reshow(emit, showing);
}
}
Future<void> _onBuried( Future<void> _onBuried(
StudySessionEventBuried event, StudySessionEventBuried event,
Emitter<StudySessionState> emit, Emitter<StudySessionState> emit,
) async { ) async {
if (state case final StudySessionStateShowing showing) { if (state case final StudySessionStateShowing showing) {
final card = showing.card; final card = showing.card;
final before = _standOf(card);
final now = clock.now().toUtc(); final now = clock.now().toUtc();
await _reviews.bury(card.id, now); await _reviews.bury(card.id, now);
_setAside.add(card.id); _setAside.add(card.id);
_lastBuried = (card: card, at: now, index: _round.indexOf(card.id)); _steps.add(_Buried(before, at: now));
await _leave(emit, card.id); await _leave(emit, card.id);
} }
} }
Future<void> _onUnburied( /// Takes back the last step: the card it was taken on shows again.
StudySessionEventUnburied event,
Emitter<StudySessionState> emit,
) async {
final buried = _lastBuried;
_lastBuried = null;
if (buried == null) {
return;
}
final (:card, :at, :index) = buried;
await _reviews.unbury(card.id, at);
_setAside.remove(card.id);
// Back where it was in the round under way; the card on screen stays.
if (state case final StudySessionStateShowing showing) {
_cardsById[card.id] = card;
_round.insert(index.clamp(0, _round.length), card.id);
_reshow(emit, showing);
}
}
Future<void> _onUndone( Future<void> _onUndone(
StudySessionEventUndone event, StudySessionEventUndone event,
Emitter<StudySessionState> emit, Emitter<StudySessionState> emit,
) async { ) async {
if (state is! StudySessionStateShowing || _undoable.isEmpty) { if (!_offersUndo || _steps.isEmpty) {
return; return;
} }
final last = _undoable.removeLast(); final step = _steps.removeLast();
final (:cardId, :at, :mode) = last.rating; await _takeBack(step);
await _reviews.undo(cardId: cardId, reviewedAt: at, mode: mode); await _bringBack(emit, step.before, show: true);
_round = last.round;
_ratings
..clear()
..addAll(last.ratings);
_graduated
..clear()
..addAll(last.graduated);
_done
..clear()
..addAll(last.done);
_given.removeRange(last.given, _given.length);
_cramIndex = last.cramIndex;
// As it was, but its time to answer starts anew.
final now = clock.now().toUtc();
emit(
_showing(
cardId,
now,
schedule: last.showing.schedule,
),
);
} }
/// Takes back the last step [matches] on the card [cardId], wherever it is
/// among the steps: the Undo on the message the step showed. The card on
/// screen stays; with none, the card comes back on screen.
Future<void> _undoStepOn(
Emitter<StudySessionState> emit,
String cardId,
bool Function(_Step) matches,
) async {
final step = _steps.reversed
.where((step) => matches(step) && step.before.card.id == cardId)
.firstOrNull;
// Taken back already, from the header.
if (step == null) {
return;
}
_steps.remove(step);
await _takeBack(step);
if (_offersUndo) {
await _bringBack(
emit,
step.before,
show: state is! StudySessionStateShowing,
);
}
}
/// Whether what is on screen offers to undo: a card, or the end of a
/// round or of what there is to study.
bool get _offersUndo => switch (state) {
StudySessionStateShowing() ||
StudySessionStateRoundDone() ||
StudySessionStateNothing() => true,
_ => false,
};
/// Takes back what [step] did to what is kept: its review or burial goes,
/// its deletion is undone.
Future<void> _takeBack(_Step step) async {
final id = step.before.card.id;
switch (step) {
case _Rated(:final at, :final mode):
await _reviews.undo(cardId: id, reviewedAt: at, mode: mode);
// Ratings are taken back last first: it was the last given.
if (step.before.session == _sessionNumber && _given.isNotEmpty) {
_given.removeLast();
}
case _Buried(:final at):
await _reviews.unbury(id, at);
_setAside.remove(id);
case _Deleted():
_deletions.undo(id);
}
}
/// Puts the card of a step taken back into the round under way: as it
/// stood before, if the step was taken in this round, else before the card
/// on screen. Shows it if [show] asks to, else the card on screen again.
Future<void> _bringBack(
Emitter<StudySessionState> emit,
_Stand before, {
required bool show,
}) async {
final id = before.card.id;
_cardsById[id] ??= before.card;
_studysetOf[id] ??= before.studysetId;
if (before.session == _sessionNumber && !before.done) {
_done.remove(id);
}
final onScreen = switch (state) {
StudySessionStateShowing(:final card) => card.id,
_ => null,
};
if (before.round == _roundNumber) {
if (!_round.contains(id)) {
_round.insert(before.index.clamp(0, _round.length), id);
}
if (before.rating case final rating?) {
_ratings[id] = rating;
} else {
_ratings.remove(id);
}
if (before.graduated) {
_graduated.add(id);
} else {
_graduated.remove(id);
}
} else if (!_round.contains(id)) {
final at = onScreen == null ? -1 : _round.indexOf(onScreen);
_round.insert(at < 0 ? _round.length : at, id);
}
final cram = _options.mode == LearnMode.cram;
if (!show) {
if (state case final StudySessionStateShowing showing) {
if (cram) {
_cramIndex = _round.indexOf(showing.card.id);
}
_reshow(emit, showing);
return;
}
}
final now = clock.now().toUtc();
if (cram) {
_cramIndex = _round.indexOf(id);
emit(_showing(id, now, schedule: null));
return;
}
emit(_showing(id, now, schedule: (await _schedules([id]))[id]));
}
/// How [card] stands in the session before a step is taken on it.
_Stand _standOf(Card card) => (
card: card,
studysetId: _studysetOf[card.id] ?? _studysetId,
session: _sessionNumber,
round: _roundNumber,
index: _round.indexOf(card.id),
rating: _ratings[card.id],
graduated: _graduated.contains(card.id),
done: _done.contains(card.id),
);
void _onLockToggled( void _onLockToggled(
StudySessionEventLockToggled event, StudySessionEventLockToggled event,
Emitter<StudySessionState> emit, Emitter<StudySessionState> emit,
@ -781,8 +848,6 @@ class StudySessionBloc(
/// Takes the card [id] out of the session, and shows the next. /// Takes the card [id] out of the session, and shows the next.
Future<void> _leave(Emitter<StudySessionState> emit, String id) async { Future<void> _leave(Emitter<StudySessionState> emit, String id) async {
// Taking back a rating from before would bring the card back.
_undoable.clear();
final at = _round.indexOf(id); final at = _round.indexOf(id);
_round.remove(id); _round.remove(id);
_cardsById.remove(id); _cardsById.remove(id);
@ -811,6 +876,18 @@ class StudySessionBloc(
final index = _cramIndex; final index = _cramIndex;
emit(_showing(_round[index], clock.now().toUtc(), schedule: null)); emit(_showing(_round[index], clock.now().toUtc(), schedule: null));
} }
@override
Future<void> close() {
// The session ends: what it deleted goes, once the time to undo that is
// over, so that the message offering it still can.
for (final step in _steps) {
if (step is _Deleted) {
_deletions.delete(step.before.card.id);
}
}
return super.close();
}
} }
/// What happens during a study session. /// What happens during a study session.
@ -848,10 +925,17 @@ final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// Set the card on screen aside until the next local day, and go on. /// Set the card on screen aside until the next local day, and go on.
final class const StudySessionEventBuried() extends StudySessionEvent; final class const StudySessionEventBuried() extends StudySessionEvent;
/// Take back setting the last card aside: it is studied again. /// Take back setting the card [cardId] aside: it is studied again.
final class const StudySessionEventUnburied() extends StudySessionEvent; final class const StudySessionEventUnburied(
/// The card set aside.
final String cardId,
) extends StudySessionEvent {
@override
List<Object?> get props => [cardId];
}
/// Take back the last rating of the round. /// Take back the last step of the session: a rating, a burial or a
/// deletion.
final class const StudySessionEventUndone() extends StudySessionEvent; final class const StudySessionEventUndone() extends StudySessionEvent;
/// Lock the screen against stray touches, or unlock it. /// Lock the screen against stray touches, or unlock it.
@ -892,8 +976,14 @@ final class const StudySessionEventReported(
List<Object?> get props => [report]; List<Object?> get props => [report];
} }
/// Take back deleting the last card: it is studied again. /// Take back deleting the card [cardId]: it is studied again.
final class const StudySessionEventDeleteUndone() extends StudySessionEvent; final class const StudySessionEventDeleteUndone(
/// The card deleted.
final String cardId,
) extends StudySessionEvent {
@override
List<Object?> get props => [cardId];
}
/// Delete the card on screen, and go on with the next. /// Delete the card on screen, and go on with the next.
final class const StudySessionEventCardDeleted() extends StudySessionEvent; final class const StudySessionEventCardDeleted() extends StudySessionEvent;
@ -970,9 +1060,12 @@ final class const StudySessionStateNothing({
/// Whether cards ask their back first. /// Whether cards ask their back first.
final bool backFirst = false, final bool backFirst = false,
/// Whether a step of the session can be taken back.
final bool canUndo = false,
}) extends StudySessionState { }) extends StudySessionState {
@override @override
List<Object?> get props => [next, backFirst]; List<Object?> get props => [next, backFirst, canUndo];
} }
/// A card on screen. /// A card on screen.
@ -1017,7 +1110,7 @@ final class const StudySessionStateShowing({
/// Cramming, whether there is a card after this one. /// Cramming, whether there is a card after this one.
required final bool canGoForward, required final bool canGoForward,
/// Whether a rating of the round can be taken back. /// Whether a step of the session can be taken back.
final bool canUndo = false, final bool canUndo = false,
/// How many chosen tags narrow the session. /// How many chosen tags narrow the session.
@ -1125,6 +1218,9 @@ final class const StudySessionStateRoundDone({
/// Whether cards ask their back first. /// Whether cards ask their back first.
final bool backFirst = false, final bool backFirst = false,
/// Whether a step of the session can be taken back.
final bool canUndo = false,
}) extends StudySessionState { }) extends StudySessionState {
@override @override
List<Object?> get props => [ List<Object?> get props => [
@ -1135,18 +1231,38 @@ final class const StudySessionStateRoundDone({
accuracy, accuracy,
duration, duration,
backFirst, backFirst,
canUndo,
]; ];
} }
/// A rating of the round as it can be taken back: the review it left, and /// How a card stood in the session just before a step was taken on it: in
/// the round as it was before it. /// which session and round, where in the round, its rating there, whether it
final class const _Undoable({ /// was done with for the round and for the session.
required final StudySessionStateShowing showing, typedef _Stand = ({
required final ({String cardId, DateTime at, StudyMode mode}) rating, Card card,
required final List<String> round, String studysetId,
required final Map<String, Rating> ratings, int session,
required final Set<String> graduated, int round,
required final Set<String> done, int index,
required final int given, Rating? rating,
required final int cramIndex, bool graduated,
bool done,
}); });
/// A step of the session that can be taken back, and how its card stood
/// [before].
sealed class const _Step(final _Stand before);
/// The card was rated [at], studied as [mode].
final class const _Rated(
super.before, {
required final DateTime at,
required final StudyMode mode,
}) extends _Step;
/// The card was set aside [at].
final class const _Buried(super.before, {required final DateTime at})
extends _Step;
/// The card was deleted; the deletion is held until the session ends.
final class const _Deleted(super.before) extends _Step;

View file

@ -2,6 +2,7 @@ import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
@ -189,9 +190,9 @@ Future<bool> _confirmLeave(BuildContext context) async {
return leave ?? false; return leave ?? false;
} }
/// Close and the title on the left; the Pomodoro timer if one is set, /// Close and the title on the left; the Pomodoro timer if one is set, undo,
/// asking the back first and the settings on the right, the settings with /// asking the back first, the lock and the settings on the right, the
/// how many tags narrow the session. /// settings with how many tags narrow the session.
class const _Header({ class const _Header({
required final String studysetId, required final String studysetId,
required final String title, required final String title,
@ -213,6 +214,13 @@ class const _Header({
StudySessionStateShowing(:final tagFilterCount) => tagFilterCount, StudySessionStateShowing(:final tagFilterCount) => tagFilterCount,
_ => 0, _ => 0,
}; };
// Whether undo is offered, and can undo something.
final canUndo = switch (state) {
StudySessionStateShowing(:final canUndo) ||
StudySessionStateRoundDone(:final canUndo) ||
StudySessionStateNothing(:final canUndo) => canUndo,
_ => null,
};
return SizedBox( return SizedBox(
height: 56, height: 56,
child: Padding( child: Padding(
@ -236,11 +244,11 @@ class const _Header({
), ),
), ),
const PomodoroChip(), const PomodoroChip(),
if (state case final StudySessionStateShowing showing) if (canUndo != null)
IconButton( IconButton(
tooltip: l10n.studyUndoLastRating, tooltip: l10n.studyUndo,
icon: const Icon(LucideIcons.undo2), icon: const Icon(LucideIcons.undo2),
onPressed: showing.canUndo onPressed: canUndo
? () => _send(context, const StudySessionEventUndone()) ? () => _send(context, const StudySessionEventUndone())
: null, : null,
), ),
@ -351,7 +359,7 @@ class const _Showing(
message: l10n.studyCardBuried, message: l10n.studyCardBuried,
onUndo: () { onUndo: () {
if (!bloc.isClosed) { if (!bloc.isClosed) {
bloc.add(const StudySessionEventUnburied()); bloc.add(StudySessionEventUnburied(card.id));
} }
}, },
); );
@ -359,6 +367,7 @@ class const _Showing(
onInfo: () => showCardInfo(context, card, studysetId: state.studysetId), onInfo: () => showCardInfo(context, card, studysetId: state.studysetId),
onDelete: () async { onDelete: () async {
final bloc = context.read<StudySessionBloc>(); final bloc = context.read<StudySessionBloc>();
final deletions = PersonScope.of(context).cardDeletions;
if (await confirmDestructiveAction( if (await confirmDestructiveAction(
context, context,
title: l10n.cardDeleteTitle, title: l10n.cardDeleteTitle,
@ -371,8 +380,11 @@ class const _Showing(
context, context,
message: l10n.cardDeleted, message: l10n.cardDeleted,
onUndo: () { onUndo: () {
if (!bloc.isClosed) { if (bloc.isClosed) {
bloc.add(const StudySessionEventDeleteUndone()); // The session ended, and the deletion took its time to undo.
deletions.undo(card.id);
} else {
bloc.add(StudySessionEventDeleteUndone(card.id));
} }
}, },
); );

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import '../../support/eventually.dart';
void main() {
late List<String> deleted;
late DelayedDeletions deletions;
setUp(() {
deleted = [];
deletions = DelayedDeletions(
(id) async => deleted.add(id),
undoWindow: const Duration(milliseconds: 10),
);
});
tearDown(() => deletions.dispose());
test('a held deletion waits past the time to undo, until it is let go: '
'then it takes its time to undo', () async {
deletions.hold('a');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deletions.pending, {'a'});
expect(deleted, isEmpty);
deletions.delete('a');
expect(deletions.pending, {'a'});
await eventually(() => deletions.pending.isEmpty);
expect(deleted, ['a']);
});
test('a held deletion can be undone', () async {
deletions
..hold('a')
..undo('a');
expect(deletions.pending, isEmpty);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
});
test('closing drops a held deletion: the item stays', () async {
deletions.hold('a');
await deletions.dispose();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
deletions = DelayedDeletions((id) async {});
});
}

View file

@ -181,19 +181,24 @@ void main() {
expect((await tags.read()).of(id), isEmpty); expect((await tags.read()).of(id), isEmpty);
}); });
test('deleted, leaves the round and the studyset', () async { test('deleted, leaves the round and every list at once, and goes for '
await cards.create('japanese', card('猫')); 'good once the session ends', () async {
final id = await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬')); await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async { await withClock(Clock(() => now), () async {
final bloc = session(); final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫'); await showing(bloc, '猫');
bloc.add(const StudySessionEventCardDeleted()); bloc.add(const StudySessionEventCardDeleted());
expect((await showing(bloc, '犬')).pills, hasLength(1)); expect((await showing(bloc, '犬')).pills, hasLength(1));
expect(deletions.pending, {id});
// Past the time to undo a deletion elsewhere, it still waits.
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(await cards.cardsOf('japanese'), hasLength(2));
await bloc.close();
}); });
// Deleted for good once the time to undo it is over.
await Future<void>.delayed(const Duration(milliseconds: 50)); await Future<void>.delayed(const Duration(milliseconds: 50));
expect( expect(
@ -205,7 +210,7 @@ void main() {
test( test(
'buried, leaves the round for the day, and comes back with Undo', 'buried, leaves the round for the day, and comes back with Undo',
() async { () async {
await cards.create('japanese', card('猫')); final id = await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬')); await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async { await withClock(Clock(() => now), () async {
@ -216,7 +221,7 @@ void main() {
bloc.add(const StudySessionEventBuried()); bloc.add(const StudySessionEventBuried());
expect((await showing(bloc, '犬')).pills, hasLength(1)); expect((await showing(bloc, '犬')).pills, hasLength(1));
bloc.add(const StudySessionEventUnburied()); bloc.add(StudySessionEventUnburied(id));
await until( await until(
bloc, bloc,
(s) => s is StudySessionStateShowing && s.pills.length == 2, (s) => s is StudySessionStateShowing && s.pills.length == 2,
@ -362,6 +367,113 @@ void main() {
expect(reviewGateway.rows, isEmpty); expect(reviewGateway.rows, isEmpty);
}); });
test('takes back burials and deletions as well, last first, each card '
'showing again where it was', () async {
for (final front in ['猫', '犬', '鳥', '魚']) {
await cards.create('japanese', card(front));
}
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '犬');
bloc.add(const StudySessionEventBuried());
await showing(bloc, '鳥');
bloc.add(const StudySessionEventCardDeleted());
expect((await showing(bloc, '魚')).pills, hasLength(2));
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '鳥')).pills, hasLength(3));
expect(deletions.pending, isEmpty);
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '犬')).pills, hasLength(4));
expect(reviewGateway.rows.map((row) => row.mode), ['review']);
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '猫')).canUndo, isFalse);
await bloc.close();
});
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(reviewGateway.rows, isEmpty);
expect(await cards.cardsOf('japanese'), hasLength(4));
});
test('reaches past the end of a round', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventRated(Rating.easy));
final done = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(done.canUndo, isTrue);
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '猫')).pills.single.rating, isNull);
});
expect(reviewGateway.rows, isEmpty);
});
test('brings a card deleted in an earlier round into the round under '
'way', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventCardDeleted());
await showing(bloc, '犬');
bloc.add(const StudySessionEventSettingsChanged());
await until(bloc, (s) => s is StudySessionStateLoading);
final dog = await showing(bloc, '犬');
expect(dog.pills, hasLength(1));
expect(dog.canUndo, isTrue);
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '猫')).pills, hasLength(2));
});
expect(deletions.pending, isEmpty);
});
test("Undo on a step's message takes back that step alone, the card on "
'screen staying', () async {
final id = await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await cards.create('japanese', card('鳥'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventCardDeleted());
await showing(bloc, '犬');
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '鳥');
bloc.add(StudySessionEventDeleteUndone(id));
final bird = await until(
bloc,
(s) => s is StudySessionStateShowing && s.pills.length == 3,
) as StudySessionStateShowing;
expect(bird.card.content.front, text('鳥'));
expect(deletions.pending, isEmpty);
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '犬')).canUndo, isFalse);
});
});
}); });
group('When something fails', () { group('When something fails', () {

View file

@ -241,7 +241,8 @@ void main() {
}); });
testWidgets('deleting a card from its menu asks, then goes on with the ' testWidgets('deleting a card from its menu asks, then goes on with the '
'next', (tester) async { 'next; the card waits for the session to end, and Undo above brings '
'it back', (tester) async {
await cards.create( await cards.create(
'japanese', 'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')), CardContent(front: paragraph('猫'), back: paragraph('cat')),
@ -261,10 +262,14 @@ void main() {
expect(find.text('Flashcard deleted'), findsOneWidget); expect(find.text('Flashcard deleted'), findsOneWidget);
expect(find.textContaining('犬', findRichText: true), findsOneWidget); expect(find.textContaining('犬', findRichText: true), findsOneWidget);
// For good once the time to undo it is over.
await tester.pump(const Duration(seconds: 5)); await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(await cards.cardsOf('japanese'), hasLength(1)); expect(find.text('Flashcard deleted'), findsNothing);
expect(await cards.cardsOf('japanese'), hasLength(2));
await tester.tap(find.byTooltip('Undo'));
await tester.pumpAndSettle();
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
}); });
testWidgets('hints open from under the question, one at a time; the ' testWidgets('hints open from under the question, one at a time; the '