feat(studyset): reset progress asks what starts afresh: flashcards, PDFs, paths

Resetting a studyset's progress started its flashcards afresh only. It
now asks what of it starts afresh, with everything inside it, naming
only what has progress and how much: its flashcards, reading its PDFs
(those read some way) and its learning paths (those with a step done),
each ticked to untick. One Undo takes all of it back: reading a PDF
afresh is undone now too, its fresh starts deleted by the moment they
were written, as the paths' are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 23:19:31 +02:00
commit b70d8e4ed2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 525 additions and 41 deletions

View file

@ -142,7 +142,7 @@ 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: 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`). `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 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), 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, 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), 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, 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.
- **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`, `deletedAt`, `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), `write` (by hand, 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`, `deletedAt`, `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), `write` (by hand, 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.

View file

@ -965,7 +965,14 @@
"studysetMenuReset": "Fortschritt zurücksetzen", "studysetMenuReset": "Fortschritt zurücksetzen",
"studysetResetTitle": "Fortschritt zurücksetzen?", "studysetResetTitle": "Fortschritt zurücksetzen?",
"studysetResetMessage": "Jede Karteikarte darin und in den enthaltenen Lernsets beginnt auf all deinen Geräten wieder als neu. Dein Lernverlauf bleibt erhalten. Du kannst dies für einige Sekunden rückgängig machen.", "studysetResetMessage": "Was du auswählst, beginnt darin und in den enthaltenen Lernsets auf all deinen Geräten von vorn. Dein Lernverlauf bleibt erhalten. Du kannst dies für einige Sekunden rückgängig machen.",
"studysetResetCards": "Karteikarten",
"studysetResetCardsDetail": "{count, plural, =1{1 Karteikarte beginnt wieder als neu} other{{count} Karteikarten beginnen wieder als neu}}",
"studysetResetPdfs": "Lesefortschritt der PDFs",
"studysetResetPdfsDetail": "{count, plural, =1{1 PDF wird wieder ab der ersten Seite gelesen} other{{count} PDFs werden wieder ab der ersten Seite gelesen}}",
"studysetResetPaths": "Lernpfade",
"studysetResetPathsDetail": "{count, plural, =1{1 Lernpfad beginnt wieder beim ersten Schritt} other{{count} Lernpfade beginnen wieder beim ersten Schritt}}",
"studysetResetNothing": "Darin gibt es noch keinen Fortschritt.",
"studysetResetConfirm": "Zurücksetzen", "studysetResetConfirm": "Zurücksetzen",
"studysetResetDone": "Fortschritt zurückgesetzt", "studysetResetDone": "Fortschritt zurückgesetzt",

View file

@ -1090,7 +1090,17 @@
"studysetMenuReset": "Reset progress", "studysetMenuReset": "Reset progress",
"studysetResetTitle": "Reset progress?", "studysetResetTitle": "Reset progress?",
"studysetResetMessage": "Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.", "studysetResetMessage": "What you choose starts afresh, in it and in the studysets inside it, on all your devices. Your study history stays. You can undo this for a few seconds.",
"studysetResetCards": "Flashcards",
"studysetResetCardsDetail": "{count, plural, =1{1 flashcard starts again as new} other{{count} flashcards start again as new}}",
"@studysetResetCardsDetail": {"placeholders": {"count": {"type": "int"}}},
"studysetResetPdfs": "Reading of PDFs",
"studysetResetPdfsDetail": "{count, plural, =1{1 PDF is read from its first page again} other{{count} PDFs are read from their first page again}}",
"@studysetResetPdfsDetail": {"placeholders": {"count": {"type": "int"}}},
"studysetResetPaths": "Learning paths",
"studysetResetPathsDetail": "{count, plural, =1{1 learning path starts from its first step again} other{{count} learning paths start from their first step again}}",
"@studysetResetPathsDetail": {"placeholders": {"count": {"type": "int"}}},
"studysetResetNothing": "Nothing in it has progress yet.",
"studysetResetConfirm": "Reset", "studysetResetConfirm": "Reset",
"studysetResetDone": "Progress reset", "studysetResetDone": "Progress reset",

View file

@ -5677,9 +5677,51 @@ abstract class AppLocalizations {
/// No description provided for @studysetResetMessage. /// No description provided for @studysetResetMessage.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.'** /// **'What you choose starts afresh, in it and in the studysets inside it, on all your devices. Your study history stays. You can undo this for a few seconds.'**
String get studysetResetMessage; String get studysetResetMessage;
/// No description provided for @studysetResetCards.
///
/// In en, this message translates to:
/// **'Flashcards'**
String get studysetResetCards;
/// No description provided for @studysetResetCardsDetail.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard starts again as new} other{{count} flashcards start again as new}}'**
String studysetResetCardsDetail(int count);
/// No description provided for @studysetResetPdfs.
///
/// In en, this message translates to:
/// **'Reading of PDFs'**
String get studysetResetPdfs;
/// No description provided for @studysetResetPdfsDetail.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 PDF is read from its first page again} other{{count} PDFs are read from their first page again}}'**
String studysetResetPdfsDetail(int count);
/// No description provided for @studysetResetPaths.
///
/// In en, this message translates to:
/// **'Learning paths'**
String get studysetResetPaths;
/// No description provided for @studysetResetPathsDetail.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 learning path starts from its first step again} other{{count} learning paths start from their first step again}}'**
String studysetResetPathsDetail(int count);
/// No description provided for @studysetResetNothing.
///
/// In en, this message translates to:
/// **'Nothing in it has progress yet.'**
String get studysetResetNothing;
/// No description provided for @studysetResetConfirm. /// No description provided for @studysetResetConfirm.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -3556,7 +3556,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetResetMessage => String get studysetResetMessage =>
'Jede Karteikarte darin und in den enthaltenen Lernsets beginnt auf all deinen Geräten wieder als neu. Dein Lernverlauf bleibt erhalten. Du kannst dies für einige Sekunden rückgängig machen.'; 'Was du auswählst, beginnt darin und in den enthaltenen Lernsets auf all deinen Geräten von vorn. Dein Lernverlauf bleibt erhalten. Du kannst dies für einige Sekunden rückgängig machen.';
@override
String get studysetResetCards => 'Karteikarten';
@override
String studysetResetCardsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten beginnen wieder als neu',
one: '1 Karteikarte beginnt wieder als neu',
);
return '$_temp0';
}
@override
String get studysetResetPdfs => 'Lesefortschritt der PDFs';
@override
String studysetResetPdfsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs werden wieder ab der ersten Seite gelesen',
one: '1 PDF wird wieder ab der ersten Seite gelesen',
);
return '$_temp0';
}
@override
String get studysetResetPaths => 'Lernpfade';
@override
String studysetResetPathsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Lernpfade beginnen wieder beim ersten Schritt',
one: '1 Lernpfad beginnt wieder beim ersten Schritt',
);
return '$_temp0';
}
@override
String get studysetResetNothing => 'Darin gibt es noch keinen Fortschritt.';
@override @override
String get studysetResetConfirm => 'Zurücksetzen'; String get studysetResetConfirm => 'Zurücksetzen';

View file

@ -3532,7 +3532,52 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetResetMessage => String get studysetResetMessage =>
'Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.'; 'What you choose starts afresh, in it and in the studysets inside it, on all your devices. Your study history stays. You can undo this for a few seconds.';
@override
String get studysetResetCards => 'Flashcards';
@override
String studysetResetCardsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards start again as new',
one: '1 flashcard starts again as new',
);
return '$_temp0';
}
@override
String get studysetResetPdfs => 'Reading of PDFs';
@override
String studysetResetPdfsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs are read from their first page again',
one: '1 PDF is read from its first page again',
);
return '$_temp0';
}
@override
String get studysetResetPaths => 'Learning paths';
@override
String studysetResetPathsDetail(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count learning paths start from their first step again',
one: '1 learning path starts from its first step again',
);
return '$_temp0';
}
@override
String get studysetResetNothing => 'Nothing in it has progress yet.';
@override @override
String get studysetResetConfirm => 'Reset'; String get studysetResetConfirm => 'Reset';

View file

@ -53,6 +53,10 @@ abstract interface class PdfSourceGateway() {
required int page, required int page,
}); });
/// Deletes the fresh starts of the PDFs [pdfIds] written at [at], as an
/// undo of them.
Future<void> deleteResets(Set<String> pdfIds, DateTime at);
/// Fires when a sync brought changes from the person's other devices. /// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges; Stream<void> get remoteChanges;
} }

View file

@ -265,16 +265,31 @@ class PdfSourceRepository(
} }
/// Starts reading the PDF [id] afresh: no page counts as reached. /// Starts reading the PDF [id] afresh: no page counts as reached.
Future<void> resetReading(String id) => _reading.run(() async { Future<void> resetReading(String id) => resetReadings({id});
await _gateway.insertReading(
pdfSourceId: id, /// Starts reading the PDFs [ids] afresh, now: no page counts as reached;
readAt: clock.now().toUtc(), /// returns when, for [undoResetReadings].
kind: PdfReadingKind.reset.name, Future<DateTime> resetReadings(Set<String> ids) => _reading.run(() async {
page: 0, final at = clock.now().toUtc();
); for (final id in ids) {
await _gateway.insertReading(
pdfSourceId: id,
readAt: at,
kind: PdfReadingKind.reset.name,
page: 0,
);
}
_changes.add(null); _changes.add(null);
return at;
}); });
/// Undoes [resetReadings] of [ids] at [at].
Future<void> undoResetReadings(Set<String> ids, DateTime at) =>
_reading.run(() async {
await _gateway.deleteResets(ids, at);
_changes.add(null);
});
/// Stops following remote changes. /// Stops following remote changes.
Future<void> dispose() async { Future<void> dispose() async {
await _remoteChanges.cancel(); await _remoteChanges.cancel();

View file

@ -140,6 +140,21 @@ class SyncPdfSourceGateway(
_sync.requestSync(); _sync.requestSync();
} }
@override
Future<void> deleteResets(Set<String> pdfIds, DateTime at) async {
if (pdfIds.isEmpty) {
return;
}
await protocol.PdfReading.db.deleteWhere(
_db,
where: (t) =>
t.pdfSourceId.inSet({for (final id in pdfIds) _uuid(id)}) &
t.kind.equals('reset') &
t.readAt.equals(at),
);
_sync.requestSync();
}
@override @override
Stream<void> get remoteChanges => _sync.merges; Stream<void> get remoteChanges => _sync.merges;

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/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/data/path_progress_repository.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/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/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
@ -115,16 +116,30 @@ final class const StudysetListEventRestoreRequested(
List<Object?> get props => [id]; List<Object?> get props => [id];
} }
/// Start the progress of every card in the studyset [id], and in what is /// Start the progress in the studyset [id], and in what is inside it,
/// inside it, afresh. /// afresh: of every card, of reading its PDFs, of its learning paths, as
/// asked.
final class const StudysetListEventResetRequested( final class const StudysetListEventResetRequested(
/// The studyset's id. /// The studyset's id.
final String id, final String id, {
) extends StudysetListEvent {
/// Whether the cards start afresh.
final bool cards = true,
/// Whether reading the PDFs starts afresh.
final bool pdfs = false,
/// Whether the learning paths start afresh.
final bool paths = false,
}) extends StudysetListEvent {
@override @override
List<Object?> get props => [id]; List<Object?> get props => [id, cards, pdfs, paths];
} }
/// How much in a studyset and inside it has progress to start afresh: its
/// flashcards, the PDFs read some way, the learning paths with a step done.
typedef StudysetProgressParts = ({int cards, int pdfs, int paths});
/// Undo the last fresh start. /// Undo the last fresh start.
final class const StudysetListEventResetUndone() extends StudysetListEvent; final class const StudysetListEventResetUndone() extends StudysetListEvent;
@ -186,6 +201,7 @@ class StudysetListBloc(
final LearningPathRepository _paths, final LearningPathRepository _paths,
final PdfSourceRepository _pdfs, final PdfSourceRepository _pdfs,
final ReviewRepository _reviews, final ReviewRepository _reviews,
final PathProgressRepository _pathProgress,
/// The person's settings, for the new cards a studyset without study /// The person's settings, for the new cards a studyset without study
/// options of its own starts a day. /// options of its own starts a day.
@ -244,14 +260,7 @@ class StudysetListBloc(
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested); on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
on<StudysetListEventRetried>((event, emit) => _studysets.reread()); on<StudysetListEventRetried>((event, emit) => _studysets.reread());
on<StudysetListEventResetRequested>((event, emit) async { on<StudysetListEventResetRequested>((event, emit) async {
final at = clock.now().toUtc(); _resetting = _reset(event);
final cardIds = [
for (final id in subtreeOf(event.id, _studysetsRead))
for (final card in await _cards.cardsOf(id)) card.id,
];
_resetting = _reviews
.reset(cardIds, at)
.then((_) => (cardIds: cardIds, at: at));
await _resetting; await _resetting;
add(const StudysetListEventProgressRefreshRequested()); add(const StudysetListEventProgressRefreshRequested());
}); });
@ -259,15 +268,95 @@ class StudysetListBloc(
final resetting = _resetting; final resetting = _resetting;
_resetting = null; _resetting = null;
if (resetting != null) { if (resetting != null) {
final (:cardIds, :at) = await resetting; await (await resetting)();
await _reviews.undoReset(cardIds, at);
add(const StudysetListEventProgressRefreshRequested()); add(const StudysetListEventProgressRefreshRequested());
} }
}); });
} }
/// The last fresh start, which an undo takes back. /// The last fresh start, as what undoes it.
Future<({List<String> cardIds, DateTime at})>? _resetting; Future<Future<void> Function()>? _resetting;
/// Starts afresh what [event] asks for, in the studyset and what is
/// inside it; returns what undoes it.
Future<Future<void> Function()> _reset(
StudysetListEventResetRequested event,
) async {
final parts = await _progressIn(event.id);
final at = clock.now().toUtc();
final cardIds = event.cards ? parts.cardIds : const <String>[];
await _reviews.reset(cardIds, at);
final pdfIds = event.pdfs ? parts.pdfIds : const <String>{};
final pdfsAt = pdfIds.isEmpty ? null : await _pdfs.resetReadings(pdfIds);
final stepIds = event.paths ? parts.stepIds : const <String>{};
final stepsAt = stepIds.isEmpty ? null : await _pathProgress.reset(stepIds);
return () async {
await _reviews.undoReset(cardIds, at);
if (pdfsAt != null) {
await _pdfs.undoResetReadings(pdfIds, pdfsAt);
}
if (stepsAt != null) {
await _pathProgress.undoReset(stepIds, stepsAt);
}
};
}
/// What has progress in the studyset [id] and inside it, to start
/// afresh: every card, the PDFs read some way, and the steps of the
/// learning paths with a step done.
Future<
({
List<String> cardIds,
Set<String> pdfIds,
int paths,
Set<String> stepIds,
})
>
_progressIn(String id) async {
final cardIds = <String>[];
final pdfIds = <String>{};
final stepIds = <String>{};
var paths = 0;
for (final studysetId in subtreeOf(id, _studysetsRead)) {
for (final card in await _cards.cardsOf(studysetId)) {
cardIds.add(card.id);
}
final pdfs = {
for (final pdf in await _pdfs.sourcesOf(studysetId)) pdf.id,
};
if (pdfs.isNotEmpty) {
for (final MapEntry(key: pdfId, value: page)
in (await _pdfs.furthestPages(pdfIds: pdfs)).entries) {
if (page > 0) {
pdfIds.add(pdfId);
}
}
}
for (final path in await _paths.pathsOf(studysetId)) {
final steps = await _paths.stepsOf(path.id);
final progress = await _pathProgress.of({
for (final step in steps) step.id: step.kind,
});
if (progress.values.any((step) => step.done)) {
paths++;
stepIds.addAll([for (final step in steps) step.id]);
}
}
}
return (cardIds: cardIds, pdfIds: pdfIds, paths: paths, stepIds: stepIds);
}
/// How much in the studyset [id] and inside it a fresh start could take
/// back to the beginning: its flashcards, the PDFs read some way, the
/// learning paths with a step done.
Future<StudysetProgressParts> progressParts(String id) async {
final parts = await _progressIn(id);
return (
cards: parts.cardIds.length,
pdfs: parts.pdfIds.length,
paths: parts.paths,
);
}
/// How often the progress was asked for, so that a slow, older working /// How often the progress was asked for, so that a slow, older working
/// out never overwrites a newer one. /// out never overwrites a newer one.

View file

@ -61,6 +61,7 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
person.paths, person.paths,
person.pdfSources, person.pdfSources,
person.reviews, person.reviews,
person.pathProgress,
person.settings, person.settings,
)..add(const StudysetListEventStarted()), )..add(const StudysetListEventStarted()),
child: BlocProvider( child: BlocProvider(

View file

@ -2,7 +2,6 @@ import 'package:flutter_bloc/flutter_bloc.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/citation/widget/source_list.dart'; import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
@ -102,27 +101,123 @@ Future<void> showBibliography(BuildContext context, Studyset studyset) {
); );
} }
/// Asks, then starts [studyset]'s progress afresh, with everything inside /// Asks what of [studyset]'s progress, with everything inside it, starts
/// it, and offers to undo that for a moment. /// afresh (its flashcards; reading its PDFs and its learning paths, where
/// they have progress), then starts it afresh and offers to undo that for a
/// moment.
Future<void> resetStudysetProgress( Future<void> resetStudysetProgress(
BuildContext context, BuildContext context,
Studyset studyset, Studyset studyset,
) async { ) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>(); final bloc = context.read<StudysetListBloc>();
if (!await confirmDestructiveAction( final parts = await bloc.progressParts(studyset.id);
context, if (!context.mounted) {
title: l10n.studysetResetTitle,
message: l10n.studysetResetMessage,
confirmLabel: l10n.studysetResetConfirm,
) ||
!context.mounted) {
return; return;
} }
bloc.add(StudysetListEventResetRequested(studyset.id)); final chosen = await showDialog<({bool cards, bool pdfs, bool paths})>(
context: context,
builder: (_) => _ResetDialog(parts: parts),
);
if (chosen == null || !context.mounted) {
return;
}
bloc.add(
StudysetListEventResetRequested(
studyset.id,
cards: chosen.cards,
pdfs: chosen.pdfs,
paths: chosen.paths,
),
);
showUndoSnackBar( showUndoSnackBar(
context, context,
message: l10n.studysetResetDone, message: l10n.studysetResetDone,
onUndo: () => bloc.add(const StudysetListEventResetUndone()), onUndo: () => bloc.add(const StudysetListEventResetUndone()),
); );
} }
/// What of a studyset's progress starts afresh: each of [parts] that has
/// any, ticked, to untick; Reset once any is ticked.
class const _ResetDialog({required final StudysetProgressParts parts})
extends StatefulWidget {
@override
State<_ResetDialog> createState() => _ResetDialogState();
}
class _ResetDialogState() extends State<_ResetDialog> {
late var _cards = widget.parts.cards > 0;
late var _pdfs = widget.parts.pdfs > 0;
late var _paths = widget.parts.paths > 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final (:cards, :pdfs, :paths) = widget.parts;
final nothing = cards == 0 && pdfs == 0 && paths == 0;
Widget part(
String title,
String detail, {
required bool value,
required ValueChanged<bool> onChanged,
}) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: value,
onChanged: (value) => setState(() => onChanged(value ?? false)),
title: Text(title),
subtitle: Text(detail),
);
return AlertDialog(
title: Text(l10n.studysetResetTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
nothing ? l10n.studysetResetNothing : l10n.studysetResetMessage,
),
if (!nothing) const SizedBox(height: 8),
if (cards > 0)
part(
l10n.studysetResetCards,
l10n.studysetResetCardsDetail(cards),
value: _cards,
onChanged: (value) => _cards = value,
),
if (pdfs > 0)
part(
l10n.studysetResetPdfs,
l10n.studysetResetPdfsDetail(pdfs),
value: _pdfs,
onChanged: (value) => _pdfs = value,
),
if (paths > 0)
part(
l10n.studysetResetPaths,
l10n.studysetResetPathsDetail(paths),
value: _paths,
onChanged: (value) => _paths = value,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(nothing ? l10n.commonClose : l10n.commonCancel),
),
if (!nothing)
FilledButton.tonal(
onPressed: _cards || _pdfs || _paths
? () => Navigator.of(
context,
).pop((cards: _cards, pdfs: _pdfs, paths: _paths))
: null,
child: Text(l10n.studysetResetConfirm),
),
],
);
}
}

View file

@ -98,6 +98,7 @@ class const StudysetScreen({
person.paths, person.paths,
person.pdfSources, person.pdfSources,
person.reviews, person.reviews,
person.pathProgress,
person.settings, person.settings,
parentId: studyset.id, parentId: studyset.id,
)..add(const StudysetListEventStarted()), )..add(const StudysetListEventStarted()),

View file

@ -7,7 +7,11 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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/data/path_progress_repository.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.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.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_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
@ -29,6 +33,7 @@ import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart'; import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart'; import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart'; import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart'; import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_review_gateway.dart'; import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart'; import '../../../support/fake_settings_gateway.dart';
@ -48,6 +53,7 @@ void main() {
late SettingsRepository settings; late SettingsRepository settings;
late FakePdfSourceGateway pdfGateway; late FakePdfSourceGateway pdfGateway;
late PdfSourceRepository pdfs; late PdfSourceRepository pdfs;
late PathProgressRepository pathProgress;
setUp(() { setUp(() {
gateway = FakeStudysetGateway(); gateway = FakeStudysetGateway();
@ -57,6 +63,7 @@ void main() {
notes = NoteRepository(FakeNoteGateway()); notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway()); paths = LearningPathRepository(FakeLearningPathGateway());
reviews = ReviewRepository(FakeReviewGateway()); reviews = ReviewRepository(FakeReviewGateway());
pathProgress = PathProgressRepository(FakePathAnswerGateway());
settingsGateway = FakeSettingsGateway(); settingsGateway = FakeSettingsGateway();
settings = SettingsRepository(settingsGateway); settings = SettingsRepository(settingsGateway);
pdfGateway = FakePdfSourceGateway(); pdfGateway = FakePdfSourceGateway();
@ -77,8 +84,20 @@ void main() {
await paths.dispose(); await paths.dispose();
await settings.dispose(); await settings.dispose();
await pdfs.dispose(); await pdfs.dispose();
await pathProgress.dispose();
}); });
/// Waits, a moment at a time, until [done].
Future<void> until(Future<bool> Function() done) async {
for (var tries = 0; tries < 100; tries++) {
if (await done()) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
fail('Never came about');
}
/// Opens the library, or inside the studyset [parentId] its topics. /// Opens the library, or inside the studyset [parentId] its topics.
void start({String? parentId}) { void start({String? parentId}) {
bloc = StudysetListBloc( bloc = StudysetListBloc(
@ -88,6 +107,7 @@ void main() {
paths, paths,
pdfs, pdfs,
reviews, reviews,
pathProgress,
settings, settings,
parentId: parentId, parentId: parentId,
)..add(const StudysetListEventStarted()); )..add(const StudysetListEventStarted());
@ -565,4 +585,58 @@ void main() {
bloc.add(const StudysetListEventResetUndone()); bloc.add(const StudysetListEventResetUndone());
expect(await dueIn(japanese), 0); expect(await dueIn(japanese), 0);
}); });
test('resetting starts reading the PDFs and the learning paths afresh '
'too, as chosen, and says what has progress; an undo takes all of '
'it back', () async {
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
await cards.create(japanese, newCard());
pdfGateway.receiveRemote(
id: 'pdf-1',
studysetId: japanese,
payload: encodePdfSourcePayload(
const PdfSourceContent(
title: 'Genki',
fileName: 'Genki.pdf',
assetId: 'a',
),
),
);
await pdfs.reached('pdf-1', 12);
final path = await paths.createPath(
japanese,
LearningPathContent.titled('Kana'),
);
final step = await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
await pathProgress.record(step.stepId, AnswerOutcome.continued);
start();
await listing(['Japanese']);
expect(await bloc.progressParts(japanese), (cards: 1, pdfs: 1, paths: 1));
bloc.add(
StudysetListEventResetRequested(japanese, cards: false, pdfs: true),
);
await until(() async => (await pdfs.furthestPages())['pdf-1'] == null);
expect(await bloc.progressParts(japanese), (cards: 1, pdfs: 0, paths: 1));
bloc.add(const StudysetListEventResetUndone());
await until(() async => (await pdfs.furthestPages())['pdf-1'] == 12);
bloc.add(
StudysetListEventResetRequested(japanese, cards: false, paths: true),
);
await until(
() async => (await bloc.progressParts(japanese)).paths == 0,
);
expect((await pdfs.furthestPages())['pdf-1'], 12);
bloc.add(const StudysetListEventResetUndone());
await until(
() async => (await bloc.progressParts(japanese)).paths == 1,
);
});
} }

View file

@ -342,6 +342,38 @@ void main() {
expect(find.text('Delete'), findsOneWidget); expect(find.text('Delete'), findsOneWidget);
}); });
testWidgets("resetting a topic's progress asks what of it starts afresh, "
'naming only what it has', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await card('kana', 'あ');
await openFromLibrary(tester, 'Japanese');
await tester.longPress(find.text('Kana'));
await tester.pumpAndSettle();
await tester.tap(find.text('Reset progress'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Reset progress?'), findsOneWidget);
expect(find.text('1 flashcard starts again as new'), findsOneWidget);
expect(find.text('Reading of PDFs'), findsNothing);
expect(find.text('Learning paths'), findsNothing);
await tester.tap(find.widgetWithText(CheckboxListTile, 'Flashcards'));
await tester.pump();
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Reset'))
.onPressed,
isNull,
);
await tester.tap(find.widgetWithText(CheckboxListTile, 'Flashcards'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Reset'));
await tester.pumpAndSettle();
expect(find.text('Progress reset'), findsOneWidget);
});
testWidgets('studies everything inside a studyset', (tester) async { testWidgets('studies everything inside a studyset', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese'); stored('kana', 'Kana', parent: 'japanese');

View file

@ -117,6 +117,15 @@ class FakePdfSourceGateway() implements PdfSourceGateway {
); );
} }
@override
Future<void> deleteResets(Set<String> pdfIds, DateTime at) async =>
_readings.removeWhere(
(_, reading) =>
reading.kind == 'reset' &&
pdfIds.contains(reading.pdfSourceId) &&
reading.readAt == at,
);
@override @override
Stream<void> get remoteChanges => _remoteChanges.stream; Stream<void> get remoteChanges => _remoteChanges.stream;