perf: counts worked out once and kept until something changes
On a copy of a real library (8,067 cards, 20,907 reviews, 20 studysets), every open list read every card (0.8 s), every review (0.25 s) and replayed schedules after every change, each on its own: the library, every studyset open on the stack and its topics. - CardRepository keeps each studyset's cards and every card's stamp until a card changes; a read begun before a change keeps nothing. - ReviewRepository keeps the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others'. - StudysetProgressRepository works out every studyset's progress once for every list and hands the last to whoever follows it next; StudysetListBloc reads it. A full recount now takes about 65 ms. Home no longer rereads every revision after each rating. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
de6af3efab
commit
301751607d
20 changed files with 836 additions and 258 deletions
|
|
@ -144,7 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics, handing the last to whoever follows it next. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
|
||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_ga
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||
|
|
@ -422,6 +423,15 @@ Future<PersonDependencies> _openPerson(
|
|||
paths,
|
||||
pdfSources,
|
||||
)..deleteExpiredOn(sync.synced);
|
||||
final studysetProgress = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfSources,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final cardDeletions = CardDeletions(cards);
|
||||
final pathDeletions = LearningPathDeletions(paths);
|
||||
|
|
@ -446,6 +456,7 @@ Future<PersonDependencies> _openPerson(
|
|||
pathProgress: pathProgress,
|
||||
pdfSources: pdfSources,
|
||||
reviews: reviews,
|
||||
studysetProgress: studysetProgress,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
|
|
@ -488,6 +499,7 @@ Future<PersonDependencies> _openPerson(
|
|||
await stepDeletions.dispose();
|
||||
await pdfDeletions.dispose();
|
||||
await discussions.dispose();
|
||||
await studysetProgress.dispose();
|
||||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -52,6 +53,10 @@ class PersonDependencies({
|
|||
/// The person's reviews.
|
||||
required final ReviewRepository reviews,
|
||||
|
||||
/// The progress of every studyset of the person, worked out once for the
|
||||
/// library and every studyset open.
|
||||
required final StudysetProgressRepository studysetProgress,
|
||||
|
||||
/// The person's tags and which cards, notes and paths have them.
|
||||
required final TagRepository tags,
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ typedef CardStamp = ({
|
|||
/// fractional index (decided 2026-09-30).
|
||||
class CardRepository(final CardGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changed());
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
|
|
@ -42,6 +42,27 @@ class CardRepository(final CardGateway _gateway) {
|
|||
/// decoded once, not on every read of its studyset.
|
||||
final _decoded = <String, ({int size, CardRevision? revision})>{};
|
||||
|
||||
/// The cards of each studyset as read since the last change, by the
|
||||
/// studyset's id (decided 2026-10-17): every screen and bloc reading the
|
||||
/// same studysets after a change (the library, each studyset open, the
|
||||
/// exam, Progress) reads them once, not once each.
|
||||
final _cardsByStudyset = <String, List<Card>>{};
|
||||
|
||||
/// Every card as [allCards] last read them, until a card changes.
|
||||
List<CardStamp>? _stamps;
|
||||
|
||||
/// Counts the changes, so a read begun before one never fills
|
||||
/// [_cardsByStudyset] or [_stamps] with what it read.
|
||||
var _generation = 0;
|
||||
|
||||
/// Forgets the cards read, and tells of the change.
|
||||
void _changed() {
|
||||
_generation++;
|
||||
_cardsByStudyset.clear();
|
||||
_stamps = null;
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Fires after every change of any card, made on this device or synced
|
||||
/// from another.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
|
@ -98,19 +119,44 @@ class CardRepository(final CardGateway _gateway) {
|
|||
/// studyset's in order, the studysets in the order given; their revisions
|
||||
/// read at once.
|
||||
Future<List<Card>> cardsIn(List<String> studysetIds) async {
|
||||
final rows = [
|
||||
final found = <String, List<Card>>{
|
||||
for (final id in studysetIds) id: ?_cardsByStudyset[id],
|
||||
};
|
||||
final missing = {
|
||||
for (final id in studysetIds)
|
||||
if (!found.containsKey(id)) id,
|
||||
};
|
||||
if (missing.isNotEmpty) {
|
||||
final generation = _generation;
|
||||
final read = await _read(missing);
|
||||
found.addAll(read);
|
||||
if (generation == _generation) {
|
||||
_cardsByStudyset.addAll(read);
|
||||
}
|
||||
}
|
||||
return [for (final id in studysetIds) ...found[id]!];
|
||||
}
|
||||
|
||||
/// Reads the cards of the studysets [studysetIds] from the database, each
|
||||
/// studyset's in order; their revisions read at once.
|
||||
Future<Map<String, List<Card>>> _read(Set<String> studysetIds) async {
|
||||
final rowsByStudyset = {
|
||||
for (final studysetId in studysetIds)
|
||||
..._sorted(await _gateway.cardsOf(studysetId)),
|
||||
];
|
||||
studysetId: _sorted(await _gateway.cardsOf(studysetId)),
|
||||
};
|
||||
final revisionsByCard = <String, List<CardRevision>>{};
|
||||
for (final row in await _gateway.revisionsOf({
|
||||
for (final rows in rowsByStudyset.values)
|
||||
for (final card in rows) card.id,
|
||||
})) {
|
||||
if (_decode(row) case final revision?) {
|
||||
(revisionsByCard[row.cardId] ??= []).add(revision);
|
||||
}
|
||||
}
|
||||
return [
|
||||
return {
|
||||
for (final MapEntry(key: studysetId, value: rows)
|
||||
in rowsByStudyset.entries)
|
||||
studysetId: List.unmodifiable([
|
||||
for (final row in rows)
|
||||
if (mergeRevisions(revisionsByCard[row.id] ?? const [])
|
||||
case final merged?)
|
||||
|
|
@ -122,7 +168,8 @@ class CardRepository(final CardGateway _gateway) {
|
|||
heads: merged.heads,
|
||||
conflicts: merged.conflicts,
|
||||
),
|
||||
];
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/// [rows] in their order: by position, and by id where two devices picked
|
||||
|
|
@ -135,9 +182,6 @@ class CardRepository(final CardGateway _gateway) {
|
|||
},
|
||||
);
|
||||
|
||||
/// Every card of the person on this device: its id, its studyset, when it
|
||||
/// was made, and whether it was imported (its first revision says where
|
||||
/// from).
|
||||
/// The card [id] on this device now; null when there is none.
|
||||
Future<Card?> byId(String id) async {
|
||||
final stamp = (await allCards()).where((card) => card.id == id).firstOrNull;
|
||||
|
|
@ -148,7 +192,15 @@ class CardRepository(final CardGateway _gateway) {
|
|||
)).where((card) => card.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
/// Every card of the person on this device: its id, its studyset, when it
|
||||
/// was made, and whether it was imported (its first revision says where
|
||||
/// from). Kept until a card changes: Home asks after every rating, and
|
||||
/// reading every revision took most of a second.
|
||||
Future<List<CardStamp>> allCards() async {
|
||||
if (_stamps case final stamps?) {
|
||||
return stamps;
|
||||
}
|
||||
final generation = _generation;
|
||||
final imported = <String>{};
|
||||
for (final revision in await _gateway.allRevisions()) {
|
||||
if (revision.parents.isEmpty &&
|
||||
|
|
@ -157,7 +209,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
imported.add(revision.cardId);
|
||||
}
|
||||
}
|
||||
return [
|
||||
final stamps = List<CardStamp>.unmodifiable([
|
||||
for (final card in await _gateway.allCards())
|
||||
(
|
||||
id: card.id,
|
||||
|
|
@ -165,7 +217,11 @@ class CardRepository(final CardGateway _gateway) {
|
|||
createdAt: card.createdAt,
|
||||
imported: imported.contains(card.id),
|
||||
),
|
||||
];
|
||||
]);
|
||||
if (generation == _generation) {
|
||||
_stamps = stamps;
|
||||
}
|
||||
return stamps;
|
||||
}
|
||||
|
||||
/// Creates a card with [content] at the end of [studysetId]; returns its
|
||||
|
|
@ -183,7 +239,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
),
|
||||
payload: encodeCardPayload(content, origin: origin),
|
||||
);
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
return id;
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +265,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
payload: encodeCardPayload(content, origin: origin),
|
||||
),
|
||||
]);
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +297,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
parents: basedOn,
|
||||
payload: encodeCardPayload(content),
|
||||
);
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
return id;
|
||||
}
|
||||
|
||||
|
|
@ -333,13 +389,13 @@ class CardRepository(final CardGateway _gateway) {
|
|||
/// day begins ([Card.isBuried]).
|
||||
Future<void> bury(String cardId, DateTime at) async {
|
||||
await _gateway.updateBuriedAt(cardId, at.toUtc());
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Takes the burial of the card [cardId] back.
|
||||
Future<void> unbury(String cardId) async {
|
||||
await _gateway.updateBuriedAt(cardId, null);
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Moves the cards [cardIds] into [studysetId], at its end in their
|
||||
|
|
@ -350,7 +406,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
after = FractionalIndexing.generateKeyBetween(after, null);
|
||||
await _gateway.updateStudyset(id, studysetId, after);
|
||||
}
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Deletes [card]. Its revisions are blanked first: the sync engine keeps
|
||||
|
|
@ -361,7 +417,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
Future<void> deleteById(String id) async {
|
||||
await _gateway.blankRevisions(id);
|
||||
await _gateway.deleteCard(id);
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Deletes every card of [studysetId] as [delete] does, before the
|
||||
|
|
@ -371,7 +427,7 @@ class CardRepository(final CardGateway _gateway) {
|
|||
await _gateway.blankRevisions(card.id);
|
||||
await _gateway.deleteCard(card.id);
|
||||
}
|
||||
_changes.add(null);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Stops following remote changes.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ abstract interface class ReviewGateway() {
|
|||
/// Every review of the person on this device, unordered.
|
||||
Future<List<ReviewRow>> allReviews();
|
||||
|
||||
/// Appends reviews; reviews are never changed.
|
||||
Future<void> insert(Iterable<ReviewRow> reviews);
|
||||
/// Appends reviews, each with an id of its own (the ids given are
|
||||
/// ignored); returns them as stored. Reviews are never changed.
|
||||
Future<List<ReviewRow>> insert(Iterable<ReviewRow> reviews);
|
||||
|
||||
/// Deletes the entries of [mode] of the cards [cardIds] made at [at], to
|
||||
/// undo them: a rating, a fresh start.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ typedef NewReview = ({
|
|||
/// the same schedules (decided 2026-09-30).
|
||||
class ReviewRepository(final ReviewGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) {
|
||||
// Another device's reviews: read the log anew when next asked.
|
||||
_forget();
|
||||
_changes.add(null);
|
||||
});
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
|
|
@ -38,14 +42,83 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
final _replayed =
|
||||
<(String, StudyMode), ({int count, int ids, CardSchedule schedule})>{};
|
||||
|
||||
/// The whole log as last read, each card's reviews by its id, kept as
|
||||
/// this device's reviews are written and read anew after a sync brought
|
||||
/// others' (decided 2026-10-17): the lists, Home, Statistics, Progress and
|
||||
/// the exam each read thousands of cards' reviews after every rating, and
|
||||
/// reading 20,000 of them took a quarter of a second each time. Null until
|
||||
/// a read asks for many cards' reviews or for all of them.
|
||||
Map<String, List<Review>>? _log;
|
||||
|
||||
/// Counts the writes and syncs, so a read of the log begun before one
|
||||
/// never keeps what it read.
|
||||
var _generation = 0;
|
||||
|
||||
/// From how many cards on a read takes the whole log, keeping it: fewer
|
||||
/// are read on their own (a study round's, a card's).
|
||||
static const _wholeLogFrom = 200;
|
||||
|
||||
/// Fires after every review recorded, here or on another device.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every review of the person on this device, of every mode.
|
||||
Future<List<Review>> all() async => [
|
||||
for (final row in await _gateway.allReviews()) ?_decode(row),
|
||||
for (final reviews in (await _wholeLog()).values) ...reviews,
|
||||
];
|
||||
|
||||
/// The whole log, read now unless it is kept already.
|
||||
Future<Map<String, List<Review>>> _wholeLog() async {
|
||||
if (_log case final log?) {
|
||||
return log;
|
||||
}
|
||||
final generation = _generation;
|
||||
final log = <String, List<Review>>{};
|
||||
for (final row in await _gateway.allReviews()) {
|
||||
if (_decode(row) case final review?) {
|
||||
(log[review.cardId] ??= []).add(review);
|
||||
}
|
||||
}
|
||||
if (generation == _generation) {
|
||||
_log = log;
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
/// Forgets the log kept, which a read begun before now won't keep either.
|
||||
void _forget() {
|
||||
_generation++;
|
||||
_log = null;
|
||||
}
|
||||
|
||||
/// Keeps [rows], just stored, in the log kept, if any.
|
||||
void _kept(List<ReviewRow> rows) {
|
||||
final log = _log;
|
||||
_generation++;
|
||||
if (log == null) {
|
||||
return;
|
||||
}
|
||||
for (final row in rows) {
|
||||
if (_decode(row) case final review?) {
|
||||
(log[review.cardId] ??= []).add(review);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the reviews of [mode] of [cardIds] made at [at] out of the log
|
||||
/// kept, if any, as they were just deleted.
|
||||
void _taken(Set<String> cardIds, DateTime at, StudyMode mode) {
|
||||
final log = _log;
|
||||
_generation++;
|
||||
if (log == null) {
|
||||
return;
|
||||
}
|
||||
for (final cardId in cardIds) {
|
||||
log[cardId]?.removeWhere(
|
||||
(review) => review.mode == mode && review.reviewedAt == at,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops following remote changes.
|
||||
Future<void> dispose() async {
|
||||
await _remoteChanges.cancel();
|
||||
|
|
@ -53,9 +126,17 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
}
|
||||
|
||||
/// The reviews of the cards [cardIds] on this device.
|
||||
Future<List<Review>> reviewsOf(Set<String> cardIds) async => [
|
||||
Future<List<Review>> reviewsOf(Set<String> cardIds) async {
|
||||
if (_log == null && cardIds.length < _wholeLogFrom) {
|
||||
return [
|
||||
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
|
||||
];
|
||||
}
|
||||
final log = await _wholeLog();
|
||||
return [
|
||||
for (final cardId in cardIds) ...?log[cardId],
|
||||
];
|
||||
}
|
||||
|
||||
/// The schedule of each card in [cardIds] for [mode] (front to back, or
|
||||
/// back to front), in that order, replayed from its reviews of that mode
|
||||
|
|
@ -143,6 +224,7 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
required Duration duration,
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) async {
|
||||
_kept(
|
||||
await _gateway.insert([
|
||||
(
|
||||
id: '',
|
||||
|
|
@ -152,7 +234,8 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
durationMs: duration.inMilliseconds,
|
||||
mode: mode.value,
|
||||
),
|
||||
]);
|
||||
]),
|
||||
);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
@ -172,14 +255,14 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _gateway.insert(rows);
|
||||
_kept(await _gateway.insert(rows));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Starts the progress of [cardIds] afresh at [at], in both directions;
|
||||
/// their reviews stay, for the history.
|
||||
Future<void> reset(Iterable<String> cardIds, DateTime at) async {
|
||||
await _gateway.insert([
|
||||
final stored = await _gateway.insert([
|
||||
for (final cardId in cardIds)
|
||||
(
|
||||
id: '',
|
||||
|
|
@ -191,6 +274,7 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
mode: StudyMode.reset.value,
|
||||
),
|
||||
]);
|
||||
_kept(stored);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +285,7 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
required StudyMode mode,
|
||||
}) async {
|
||||
await _gateway.deleteReviews({cardId}, reviewedAt.toUtc(), mode.value);
|
||||
_taken({cardId}, reviewedAt.toUtc(), mode);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +296,7 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
|||
at.toUtc(),
|
||||
StudyMode.reset.value,
|
||||
);
|
||||
_taken({...cardIds}, at.toUtc(), StudyMode.reset);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class SyncReviewGateway(
|
|||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(Iterable<ReviewRow> reviews) async {
|
||||
Future<List<ReviewRow>> insert(Iterable<ReviewRow> reviews) async {
|
||||
final rows = [
|
||||
for (final review in reviews)
|
||||
protocol.ReviewLog(
|
||||
|
|
@ -58,11 +58,22 @@ class SyncReviewGateway(
|
|||
),
|
||||
];
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
return const [];
|
||||
}
|
||||
// Each row gets its UUID v7 on the device; the ids given are ignored.
|
||||
await protocol.ReviewLog.db.insert(_db, rows);
|
||||
final stored = await protocol.ReviewLog.db.insert(_db, rows);
|
||||
_sync.requestSync();
|
||||
return [
|
||||
for (final review in stored)
|
||||
(
|
||||
id: review.id!.uuid,
|
||||
cardId: review.cardId.uuid,
|
||||
rating: review.rating,
|
||||
reviewedAt: review.reviewedAt,
|
||||
durationMs: review.durationMs,
|
||||
mode: review.mode,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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/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/path_progress_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/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -186,9 +180,10 @@ final class const StudysetListStateLoaded(
|
|||
}
|
||||
|
||||
/// The library: the person's studysets with the progress of their cards
|
||||
/// and how many notes and paths they hold, following every change, local or
|
||||
/// synced, and those waiting in Recently deleted; creates, edits, stars,
|
||||
/// archives and restores studysets. Given a [_parentId], it lists that
|
||||
/// and how many notes and paths they hold (worked out once for every list,
|
||||
/// [StudysetProgressRepository]), following every change, local or synced,
|
||||
/// and those waiting in Recently deleted; creates, edits, stars, archives,
|
||||
/// restores and starts studysets afresh. Given a [_parentId], it lists that
|
||||
/// studyset's children instead, for its topics, and creates studysets
|
||||
/// inside it.
|
||||
///
|
||||
|
|
@ -196,16 +191,12 @@ final class const StudysetListStateLoaded(
|
|||
/// `deleteStudysetWithUndo`): it outlives the screen it started on.
|
||||
class StudysetListBloc(
|
||||
final StudysetRepository _studysets,
|
||||
final StudysetProgressRepository _progressOfAll,
|
||||
final CardRepository _cards,
|
||||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final PdfSourceRepository _pdfs,
|
||||
final ReviewRepository _reviews,
|
||||
final PathProgressRepository _pathProgress,
|
||||
|
||||
/// The person's settings, for the new cards a studyset without study
|
||||
/// options of its own starts a day.
|
||||
final SettingsRepository _settings, {
|
||||
final PathProgressRepository _pathProgress, {
|
||||
|
||||
/// The studyset whose children to list; the library's top level when
|
||||
/// null.
|
||||
|
|
@ -257,7 +248,9 @@ class StudysetListBloc(
|
|||
on<StudysetListEventRestoreRequested>(
|
||||
(event, emit) => _studysets.restore(event.id),
|
||||
);
|
||||
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
|
||||
on<StudysetListEventProgressRefreshRequested>(
|
||||
(event, emit) => _progressOfAll.refresh(),
|
||||
);
|
||||
on<StudysetListEventRetried>((event, emit) => _studysets.reread());
|
||||
on<StudysetListEventResetRequested>((event, emit) async {
|
||||
_resetting = _reset(event);
|
||||
|
|
@ -358,12 +351,8 @@ class StudysetListBloc(
|
|||
);
|
||||
}
|
||||
|
||||
/// How often the progress was asked for, so that a slow, older working
|
||||
/// out never overwrites a newer one.
|
||||
/// Whether progress is being worked out, and whether to work it out
|
||||
/// again once done, something having changed meanwhile.
|
||||
var _working = false;
|
||||
var _workAgain = false;
|
||||
/// The progress of every studyset as last worked out.
|
||||
var _progressRead = const <String, StudyProgress>{};
|
||||
|
||||
/// The person's studysets as last read.
|
||||
var _studysetsRead = const <Studyset>[];
|
||||
|
|
@ -381,16 +370,10 @@ class StudysetListBloc(
|
|||
/// The list as last read, with the progress last worked out.
|
||||
StudysetListStateLoaded get _loaded => StudysetListStateLoaded(
|
||||
_shown,
|
||||
progress: _progress,
|
||||
progress: _progressRead,
|
||||
deleted: _deletedRead,
|
||||
);
|
||||
|
||||
/// The last progress worked out.
|
||||
Map<String, StudyProgress> get _progress => switch (state) {
|
||||
StudysetListStateLoaded(:final progress) => progress,
|
||||
_ => const {},
|
||||
};
|
||||
|
||||
Future<void> _onStarted(
|
||||
StudysetListEventStarted event,
|
||||
Emitter<StudysetListState> emit,
|
||||
|
|
@ -399,8 +382,6 @@ class StudysetListBloc(
|
|||
_studysets.watchAll(),
|
||||
onData: (studysets) {
|
||||
_studysetsRead = studysets;
|
||||
add(const StudysetListEventProgressRefreshRequested());
|
||||
// Until the new progress is worked out, the last one stands.
|
||||
return _loaded;
|
||||
},
|
||||
onError: (_, _) => const StudysetListStateFailed(),
|
||||
|
|
@ -416,142 +397,16 @@ class StudysetListBloc(
|
|||
},
|
||||
onError: (_, _) {},
|
||||
),
|
||||
// Changed cards and notes, here or on another device, change the
|
||||
// progress too.
|
||||
emit.onEach(
|
||||
_cards.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_notes.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_paths.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_pdfs.changes,
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
),
|
||||
// So do the person's new cards a day, which studysets without study
|
||||
// options of their own follow.
|
||||
emit.onEach(
|
||||
_settings.watch(),
|
||||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
onError: (_, _) {},
|
||||
_progressOfAll.watch(),
|
||||
onData: (progress) {
|
||||
_progressRead = progress;
|
||||
if (state is StudysetListStateLoaded) {
|
||||
emit(_loaded);
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
/// Works the progress out, one at a time: requests while it runs (six
|
||||
/// streams ask after a single change) make one more run after it, not
|
||||
/// one each.
|
||||
Future<void> _onProgressRefreshRequested(
|
||||
StudysetListEventProgressRefreshRequested event,
|
||||
Emitter<StudysetListState> emit,
|
||||
) async {
|
||||
if (_working) {
|
||||
_workAgain = true;
|
||||
return;
|
||||
}
|
||||
_working = true;
|
||||
try {
|
||||
do {
|
||||
_workAgain = false;
|
||||
await _workOutProgress(emit);
|
||||
} while (_workAgain && !isClosed);
|
||||
} finally {
|
||||
_working = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _workOutProgress(Emitter<StudysetListState> emit) async {
|
||||
if (state case StudysetListStateLoaded(:final studysets)) {
|
||||
final now = clock.now().toUtc();
|
||||
final startOfToday = startOfLocalDay(now);
|
||||
final progress = <String, StudyProgress>{};
|
||||
try {
|
||||
final parent = _studysetsRead
|
||||
.where((studyset) => studyset.id == _parentId)
|
||||
.firstOrNull;
|
||||
final notes = await _notes.countsByStudyset();
|
||||
final paths = await _paths.countsByStudyset();
|
||||
final files = await _pdfs.countsByStudyset();
|
||||
final study = (await _settings.read()).study;
|
||||
final newPerDay = study.newPerDay;
|
||||
StudysetStudyOptions optionsOf(String id) =>
|
||||
_studysetsRead
|
||||
.where((each) => each.id == id)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.studyOptions ??
|
||||
StudysetStudyOptions(newPerDay: newPerDay);
|
||||
// Each studyset's cards read once, though they count for every
|
||||
// studyset above them too; every schedule worked out at once.
|
||||
final cardsIn = <String, List<Card>>{};
|
||||
final listed = [
|
||||
for (final studyset in [...studysets, ?parent])
|
||||
(
|
||||
studyset: studyset,
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
studied: studyset.isArchived
|
||||
? subtreeOf(studyset.id, _studysetsRead)
|
||||
: studiedIn(studyset.id, _studysetsRead),
|
||||
),
|
||||
];
|
||||
for (final (studyset: _, :studied) in listed) {
|
||||
for (final id in studied) {
|
||||
cardsIn[id] ??= await _cards.cardsOf(id);
|
||||
}
|
||||
}
|
||||
// Each on the schedule a session studies it on: a card written by
|
||||
// hand falls due as written.
|
||||
final schedules = await _reviews.schedulesAsStudied([
|
||||
for (final cards in cardsIn.values) ...cards,
|
||||
], study);
|
||||
for (final (:studyset, :studied) in listed) {
|
||||
final groups = [
|
||||
for (final id in studied)
|
||||
[for (final card in cardsIn[id]!) card.id],
|
||||
];
|
||||
progress[studyset.id] =
|
||||
studyProgress(
|
||||
groups: groups,
|
||||
options: [for (final id in studied) optionsOf(id)],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
setAside: {
|
||||
for (final id in studied)
|
||||
for (final card in cardsIn[id]!)
|
||||
if (card.isBuried(startOfToday)) card.id,
|
||||
},
|
||||
).withContent(
|
||||
notes: studied.fold(0, (sum, id) => sum + (notes[id] ?? 0)),
|
||||
paths: studied.fold(0, (sum, id) => sum + (paths[id] ?? 0)),
|
||||
files: studied.fold(0, (sum, id) => sum + (files[id] ?? 0)),
|
||||
);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
// The last progress stands until the next change works it out.
|
||||
log('Working out the progress failed', name: 'Library', error: error);
|
||||
return;
|
||||
}
|
||||
if (state case StudysetListStateLoaded(
|
||||
:final studysets,
|
||||
:final deleted,
|
||||
)) {
|
||||
emit(
|
||||
StudysetListStateLoaded(
|
||||
studysets,
|
||||
progress: progress,
|
||||
deleted: deleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level studysets, starred ones first, each group most recently
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.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/learning_path/data/learning_path_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/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The progress of every studyset of the person, by its id (decided
|
||||
/// 2026-10-17): worked out once for all of them and kept while the person's
|
||||
/// data is open, so the library, every studyset open and its topics show
|
||||
/// it at once and share one working out, rather than each working out its
|
||||
/// own after every change. It is worked out again after every change of
|
||||
/// the studysets, their cards, notes, paths, PDFs or reviews, or of the
|
||||
/// study settings, one working out at a time, and when asked ([refresh]):
|
||||
/// what is due moves on with the clock too.
|
||||
class StudysetProgressRepository({
|
||||
required final StudysetRepository _studysets,
|
||||
required final CardRepository _cards,
|
||||
required final NoteRepository _notes,
|
||||
required final LearningPathRepository _paths,
|
||||
required final PdfSourceRepository _pdfs,
|
||||
required final ReviewRepository _reviews,
|
||||
required final SettingsRepository _settings,
|
||||
}) {
|
||||
final _progress = StreamController<Map<String, StudyProgress>>.broadcast();
|
||||
|
||||
/// The progress last worked out; null until then.
|
||||
Map<String, StudyProgress>? _last;
|
||||
|
||||
/// What it follows, once something follows it.
|
||||
List<StreamSubscription<Object?>>? _following;
|
||||
|
||||
/// The studysets and the study settings as last seen: following them
|
||||
/// anew anywhere hands every listener the same again, which changes
|
||||
/// nothing.
|
||||
List<Studyset>? _studysetsSeen;
|
||||
StudyOptions? _studySeen;
|
||||
|
||||
/// Whether it is being worked out, and whether to work it out again once
|
||||
/// done, something having changed meanwhile.
|
||||
var _working = false;
|
||||
var _again = false;
|
||||
|
||||
/// The progress of every studyset, by its id, now (once worked out) and
|
||||
/// after every change. A studyset deleted, or waiting in Recently
|
||||
/// deleted, has none.
|
||||
Stream<Map<String, StudyProgress>> watch() => Stream.multi((listener) {
|
||||
final subscription = _progress.stream.listen(listener.add);
|
||||
listener.onCancel = subscription.cancel;
|
||||
if (_last case final last?) {
|
||||
listener.add(last);
|
||||
}
|
||||
_follow();
|
||||
});
|
||||
|
||||
/// Works the progress out again: after studying, say, or as the day
|
||||
/// moves on.
|
||||
void refresh() => unawaited(_workOut());
|
||||
|
||||
/// Stops following changes.
|
||||
Future<void> dispose() async {
|
||||
for (final subscription
|
||||
in _following ?? const <StreamSubscription<Object?>>[]) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
await _progress.close();
|
||||
}
|
||||
|
||||
void _follow() {
|
||||
if (_following != null) {
|
||||
return;
|
||||
}
|
||||
_following = [
|
||||
_studysets.watchAll().listen((studysets) {
|
||||
if (!_same(studysets, _studysetsSeen)) {
|
||||
_studysetsSeen = studysets;
|
||||
refresh();
|
||||
}
|
||||
}, onError: (Object _) {}),
|
||||
for (final changes in [
|
||||
_cards.changes,
|
||||
_notes.changes,
|
||||
_paths.changes,
|
||||
_pdfs.changes,
|
||||
_reviews.changes,
|
||||
])
|
||||
changes.listen((_) => refresh()),
|
||||
// The new cards a day of studysets without study options of their
|
||||
// own come from the settings.
|
||||
_settings.watch().listen((settings) {
|
||||
if (settings.study != _studySeen) {
|
||||
_studySeen = settings.study;
|
||||
refresh();
|
||||
}
|
||||
}, onError: (Object _) {}),
|
||||
];
|
||||
}
|
||||
|
||||
/// Works the progress out, one at a time: changes while it runs (a sync
|
||||
/// brings many) make one more run after it, not one each.
|
||||
Future<void> _workOut() async {
|
||||
if (_working) {
|
||||
_again = true;
|
||||
return;
|
||||
}
|
||||
_working = true;
|
||||
try {
|
||||
do {
|
||||
_again = false;
|
||||
final progress = await _progressNow();
|
||||
if (_progress.isClosed) {
|
||||
return;
|
||||
}
|
||||
if (!_sameProgress(progress, _last)) {
|
||||
_last = progress;
|
||||
_progress.add(progress);
|
||||
}
|
||||
} while (_again);
|
||||
} on Object catch (error) {
|
||||
// The last progress stands until the next change works it out.
|
||||
log('Working out the progress failed', name: 'Progress', error: error);
|
||||
} finally {
|
||||
_working = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, StudyProgress>> _progressNow() async {
|
||||
final all = _studysetsSeen = await _studysets.all();
|
||||
final now = clock.now().toUtc();
|
||||
final startOfToday = startOfLocalDay(now);
|
||||
final notes = await _notes.countsByStudyset();
|
||||
final paths = await _paths.countsByStudyset();
|
||||
final files = await _pdfs.countsByStudyset();
|
||||
final study = _studySeen = (await _settings.read()).study;
|
||||
final options = {
|
||||
for (final studyset in all)
|
||||
studyset.id:
|
||||
studyset.content.studyOptions ??
|
||||
StudysetStudyOptions(newPerDay: study.newPerDay),
|
||||
};
|
||||
// Each studyset's cards read once, though they count for every studyset
|
||||
// above them too; every schedule worked out at once.
|
||||
final cardsIn = <String, List<Card>>{
|
||||
for (final studyset in all)
|
||||
studyset.id: await _cards.cardsOf(studyset.id),
|
||||
};
|
||||
// Each on the schedule a session studies it on: a card written by hand
|
||||
// falls due as written.
|
||||
final schedules = await _reviews.schedulesAsStudied([
|
||||
for (final cards in cardsIn.values) ...cards,
|
||||
], study);
|
||||
final buried = {
|
||||
for (final cards in cardsIn.values)
|
||||
for (final card in cards)
|
||||
if (card.isBuried(startOfToday)) card.id,
|
||||
};
|
||||
return {
|
||||
for (final studyset in all)
|
||||
studyset.id: () {
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
final studied = studyset.isArchived
|
||||
? subtreeOf(studyset.id, all)
|
||||
: studiedIn(studyset.id, all);
|
||||
final groups = [
|
||||
for (final id in studied)
|
||||
[for (final card in cardsIn[id] ?? const <Card>[]) card.id],
|
||||
];
|
||||
return studyProgress(
|
||||
groups: groups,
|
||||
options: [for (final id in studied) options[id]!],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
setAside: buried,
|
||||
).withContent(
|
||||
notes: studied.fold(0, (sum, id) => sum + (notes[id] ?? 0)),
|
||||
paths: studied.fold(0, (sum, id) => sum + (paths[id] ?? 0)),
|
||||
files: studied.fold(0, (sum, id) => sum + (files[id] ?? 0)),
|
||||
);
|
||||
}(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether [a] and [b] hold the same progress of the same studysets.
|
||||
static bool _sameProgress(
|
||||
Map<String, StudyProgress> a,
|
||||
Map<String, StudyProgress>? b,
|
||||
) =>
|
||||
b != null &&
|
||||
a.length == b.length &&
|
||||
a.entries.every((entry) => b[entry.key] == entry.value);
|
||||
|
||||
/// Whether [a] and [b] hold the same studysets, alike, in the same order.
|
||||
static bool _same(List<Studyset> a, List<Studyset>? b) {
|
||||
if (b == null || a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -58,13 +58,12 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
return BlocProvider(
|
||||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
person.studysetProgress,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pdfSources,
|
||||
person.reviews,
|
||||
person.pathProgress,
|
||||
person.settings,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: BlocProvider(
|
||||
// Anew once the person has an identity, which publishing takes.
|
||||
|
|
|
|||
|
|
@ -89,13 +89,12 @@ class const StudysetScreen({
|
|||
BlocProvider(
|
||||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
person.studysetProgress,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pdfSources,
|
||||
person.reviews,
|
||||
person.pathProgress,
|
||||
person.settings,
|
||||
parentId: studyset.id,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -65,6 +66,8 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
);
|
||||
final pdfSources = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
final tags = TagRepository(FakeTagGateway());
|
||||
final reviews = ReviewRepository(FakeReviewGateway());
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
return PersonDependencies(
|
||||
sync: SyncService(({required onMerge}) async {}),
|
||||
studysets: studysets,
|
||||
|
|
@ -73,9 +76,18 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
paths: paths,
|
||||
pathProgress: PathProgressRepository(FakePathAnswerGateway()),
|
||||
pdfSources: pdfSources,
|
||||
reviews: ReviewRepository(FakeReviewGateway()),
|
||||
reviews: reviews,
|
||||
studysetProgress: StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfSources,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
),
|
||||
tags: tags,
|
||||
settings: SettingsRepository(FakeSettingsGateway()),
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
deletions: StudysetDeletions(studysets, cards, notes, paths, pdfSources),
|
||||
noteDeletions: NoteDeletions(notes),
|
||||
|
|
|
|||
|
|
@ -591,6 +591,8 @@ void main() {
|
|||
..add(const CardComposerEventDeleted('new'))
|
||||
..add(CardComposerEventDeleted(neko));
|
||||
await until((s) => s.items.isEmpty);
|
||||
// The list empties at once; the stored card goes after.
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await cards.cardsOf('japanese'), isEmpty);
|
||||
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
|
|
@ -12,6 +13,24 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
|
||||
/// A [FakeCardGateway] counting how often it reads a studyset's cards.
|
||||
class _CountingCardGateway() extends FakeCardGateway {
|
||||
int reads = 0;
|
||||
int wholeReads = 0;
|
||||
|
||||
@override
|
||||
Future<List<CardRow>> cardsOf(String studysetId) {
|
||||
reads++;
|
||||
return super.cardsOf(studysetId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CardRevisionRow>> allRevisions() {
|
||||
wholeReads++;
|
||||
return super.allRevisions();
|
||||
}
|
||||
}
|
||||
|
||||
RichContent text(String text) => RichContent({
|
||||
'document': {'type': 'page', 'note': text},
|
||||
});
|
||||
|
|
@ -334,6 +353,58 @@ void main() {
|
|||
expect(await repository.cardsOf('kanji'), hasLength(1));
|
||||
});
|
||||
|
||||
test("reads a studyset's cards once until something changes, here or "
|
||||
'synced from elsewhere', () async {
|
||||
final counting = _CountingCardGateway();
|
||||
final cards = CardRepository(counting);
|
||||
addTearDown(cards.dispose);
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('french', card('chat', 'cat'));
|
||||
final reads = counting.reads;
|
||||
|
||||
expect(fronts(await cards.cardsOf('japanese')), ['猫']);
|
||||
expect(fronts(await cards.cardsIn(['japanese', 'french'])), ['猫', 'chat']);
|
||||
expect(fronts(await cards.cardsOf('japanese')), ['猫']);
|
||||
expect(counting.reads, reads + 2, reason: 'each studyset read once');
|
||||
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
expect(fronts(await cards.cardsOf('japanese')), ['猫', '犬']);
|
||||
|
||||
counting.receiveRemoteRevision(
|
||||
id: 'rev-9000',
|
||||
cardId: id,
|
||||
parents: (await cards.cardsOf('japanese')).first.heads,
|
||||
payload: encodeCardPayload(card('ねこ', 'cat')),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(fronts(await cards.cardsOf('japanese')), ['ねこ', '犬']);
|
||||
});
|
||||
|
||||
test('reads every card once until one changes', () async {
|
||||
final counting = _CountingCardGateway();
|
||||
final cards = CardRepository(counting);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
|
||||
expect(await cards.allCards(), hasLength(1));
|
||||
expect(await cards.allCards(), hasLength(1));
|
||||
expect(counting.wholeReads, 1);
|
||||
|
||||
await cards.create('french', card('chat', 'cat'));
|
||||
expect(await cards.allCards(), hasLength(2));
|
||||
expect(counting.wholeReads, 2);
|
||||
});
|
||||
|
||||
test('a read begun before a change keeps nothing of it for later', () async {
|
||||
await repository.create('japanese', card('猫', 'cat'));
|
||||
|
||||
final reading = repository.cardsOf('japanese');
|
||||
await repository.create('japanese', card('犬', 'dog'));
|
||||
await reading;
|
||||
|
||||
expect(fronts(await repository.cardsOf('japanese')), ['猫', '犬']);
|
||||
});
|
||||
|
||||
test('follows edits from other devices', () async {
|
||||
await repository.create('japanese', card('猫', 'cat'));
|
||||
final created = (await repository.cardsOf('japanese')).single;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
|
|
@ -6,6 +9,27 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
|||
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
/// A [FakeReviewGateway] counting how often it reads the whole log, whose
|
||||
/// [receiveRemote] plays a sync bringing another device's review.
|
||||
class _CountingReviewGateway() extends FakeReviewGateway {
|
||||
int wholeReads = 0;
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Future<List<ReviewRow>> allReviews() {
|
||||
wholeReads++;
|
||||
return super.allReviews();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
|
||||
void receiveRemote(ReviewRow row) {
|
||||
rows.add(row);
|
||||
_remote.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeReviewGateway gateway;
|
||||
late ReviewRepository reviews;
|
||||
|
|
@ -35,6 +59,67 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test("keeps the whole log once many cards' reviews are asked for, with "
|
||||
"this device's ratings, undos and fresh starts in it; a sync bringing "
|
||||
"others' reads it anew", () async {
|
||||
final counting = _CountingReviewGateway();
|
||||
final repository = ReviewRepository(counting);
|
||||
addTearDown(repository.dispose);
|
||||
final cards = {for (var i = 0; i < 250; i++) 'card-$i'};
|
||||
final at = DateTime.utc(2026, 10, 5, 9);
|
||||
final later = at.add(const Duration(minutes: 1));
|
||||
Future<void> rate(String card, DateTime when) => repository.record(
|
||||
cardId: card,
|
||||
rating: Rating.good,
|
||||
reviewedAt: when,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
Future<List<(String, StudyMode)>> kept() async => [
|
||||
for (final review in await repository.reviewsOf(cards))
|
||||
(review.cardId, review.mode),
|
||||
];
|
||||
await rate('card-0', at);
|
||||
|
||||
// A few cards' reviews are read on their own.
|
||||
expect(await repository.reviewsOf({'card-0'}), hasLength(1));
|
||||
expect(counting.wholeReads, 0);
|
||||
expect(await kept(), [('card-0', StudyMode.review)]);
|
||||
expect(counting.wholeReads, 1);
|
||||
|
||||
await rate('card-1', at);
|
||||
await rate('card-2', later);
|
||||
await repository.undo(
|
||||
cardId: 'card-2',
|
||||
reviewedAt: later,
|
||||
mode: StudyMode.review,
|
||||
);
|
||||
await repository.reset(['card-0'], later);
|
||||
|
||||
expect(
|
||||
await kept(),
|
||||
unorderedEquals([
|
||||
('card-0', StudyMode.review),
|
||||
('card-0', StudyMode.reset),
|
||||
('card-1', StudyMode.review),
|
||||
]),
|
||||
);
|
||||
expect(await repository.all(), hasLength(3));
|
||||
expect(counting.wholeReads, 1);
|
||||
|
||||
counting.receiveRemote((
|
||||
id: 'remote',
|
||||
cardId: 'card-3',
|
||||
rating: Rating.good.value,
|
||||
reviewedAt: later,
|
||||
durationMs: 1000,
|
||||
mode: StudyMode.review.value,
|
||||
));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(await repository.all(), hasLength(4));
|
||||
expect(counting.wholeReads, 2);
|
||||
});
|
||||
|
||||
test('reads only the reviews of the cards asked for', () async {
|
||||
for (final card in ['a', 'b']) {
|
||||
await reviews.record(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:fake_async/fake_async.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.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/learning_path/data/learning_path_repository.dart';
|
||||
|
|
@ -15,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_p
|
|||
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/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
|
||||
|
|
@ -34,23 +34,24 @@ import '../../../support/fake_studyset_gateway.dart';
|
|||
import '../../../support/fake_tag_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
/// A [FakeCardGateway] counting how often it reads a studyset's cards:
|
||||
/// reading the plans reads each studied studyset's once, and so does
|
||||
/// reading the progress with the exam chosen.
|
||||
class _CountingCardGateway() extends FakeCardGateway {
|
||||
/// A [FakeReviewGateway] counting how often it reads cards' reviews: the
|
||||
/// progress with an exam chosen reads them once each time it is read; the
|
||||
/// plans never do. (The cards themselves come from the card repository's
|
||||
/// cache while none changes.)
|
||||
class _CountingReviewGateway() extends FakeReviewGateway {
|
||||
int reads = 0;
|
||||
|
||||
@override
|
||||
Future<List<CardRow>> cardsOf(String studysetId) {
|
||||
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) {
|
||||
reads++;
|
||||
return super.cardsOf(studysetId);
|
||||
return super.reviewsOf(cardIds);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final today = DateTime(2026, 10, 14, 9);
|
||||
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
|
||||
late _CountingCardGateway cardGateway;
|
||||
late _CountingReviewGateway reviewGateway;
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
|
|
@ -72,9 +73,9 @@ void main() {
|
|||
/// JLPT-N5 and 本 untagged, and its cubit once its first reading is done
|
||||
/// and every change has settled.
|
||||
void start(FakeAsync async, {ExamChoice? choice}) {
|
||||
cardGateway = _CountingCardGateway();
|
||||
reviewGateway = _CountingReviewGateway();
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(cardGateway);
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
pathProgress = PathProgressRepository(FakePathAnswerGateway());
|
||||
tags = TagRepository(
|
||||
|
|
@ -82,7 +83,7 @@ void main() {
|
|||
cardChanges: cards.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
reviews = ReviewRepository(reviewGateway);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
|
|
@ -129,19 +130,19 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
test('reads no cards when a review is recorded without an exam '
|
||||
test('reads no progress when a review is recorded without an exam '
|
||||
'chosen', () {
|
||||
fakeAsync((async) {
|
||||
start(async);
|
||||
expect(cubit.state.plans, const [
|
||||
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
|
||||
]);
|
||||
final reads = cardGateway.reads;
|
||||
final reads = reviewGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
|
||||
expect(cardGateway.reads, reads);
|
||||
expect(reviewGateway.reads, reads);
|
||||
unawaited(cubit.close());
|
||||
async.flushMicrotasks();
|
||||
}, initialTime: today);
|
||||
|
|
@ -155,23 +156,23 @@ void main() {
|
|||
run(async, () => cubit.choose(exam.plan, exam.date));
|
||||
expect(cubit.state.choice, exam);
|
||||
expect(cubit.state.progress?.newCards, 2);
|
||||
final reads = cardGateway.reads;
|
||||
final reads = reviewGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
review(async, inu);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(cardGateway.reads, reads, reason: 'still settling');
|
||||
expect(reviewGateway.reads, reads, reason: 'still settling');
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
// One studyset's cards read once: the progress, not the plans.
|
||||
expect(cardGateway.reads, reads + 1);
|
||||
// The progress read once for both ratings.
|
||||
expect(reviewGateway.reads, reads + 1);
|
||||
expect(cubit.state.progress?.newCards, 0);
|
||||
|
||||
review(async, neko);
|
||||
unawaited(cubit.close());
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
expect(cardGateway.reads, reads + 1);
|
||||
expect(reviewGateway.reads, reads + 1);
|
||||
}, initialTime: today);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dar
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
|
@ -54,6 +55,7 @@ void main() {
|
|||
late FakePdfSourceGateway pdfGateway;
|
||||
late PdfSourceRepository pdfs;
|
||||
late PathProgressRepository pathProgress;
|
||||
late StudysetProgressRepository progressOfAll;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
|
|
@ -75,9 +77,19 @@ void main() {
|
|||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
progressOfAll = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfs,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
await progressOfAll.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
|
|
@ -102,13 +114,12 @@ void main() {
|
|||
void start({String? parentId}) {
|
||||
bloc = StudysetListBloc(
|
||||
studysets,
|
||||
progressOfAll,
|
||||
cards,
|
||||
notes,
|
||||
paths,
|
||||
pdfs,
|
||||
reviews,
|
||||
pathProgress,
|
||||
settings,
|
||||
parentId: parentId,
|
||||
)..add(const StudysetListEventStarted());
|
||||
}
|
||||
|
|
@ -200,7 +211,14 @@ void main() {
|
|||
(current) => current.withStudy(current.study.copyWith(writeByHand: true)),
|
||||
);
|
||||
start();
|
||||
expect((await listing(['Japanese'])).progress[japanese]?.due, 1);
|
||||
// Worked out again as the settings change.
|
||||
await until(
|
||||
() async => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) =>
|
||||
progress[japanese]?.due == 1,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves a card buried today out of what is due', () async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/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/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/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
late StudysetProgressRepository progress;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
final pdfs = PdfSourceRepository(
|
||||
FakePdfSourceGateway(),
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
progress = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfs,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await progress.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await reviews.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
await pdfs.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(front: RichContent.plain('猫'), back: RichContent.plain('cat')),
|
||||
);
|
||||
|
||||
/// Everything [progress] hands one following it, as it comes.
|
||||
List<Map<String, StudyProgress>> follow() {
|
||||
final seen = <Map<String, StudyProgress>>[];
|
||||
final subscription = progress.watch().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('works out every studyset with what is inside it, once for all '
|
||||
'following it; one following later gets it at once, worked out '
|
||||
'no more', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await card(japanese);
|
||||
await card(kana);
|
||||
|
||||
final first = follow();
|
||||
await pumpEventQueue();
|
||||
expect(first, hasLength(1));
|
||||
expect(first.single[japanese]?.cards, 2);
|
||||
expect(first.single[kana]?.cards, 1);
|
||||
|
||||
final second = follow();
|
||||
await pumpEventQueue();
|
||||
expect(second, [first.single]);
|
||||
expect(first, hasLength(1), reason: 'nothing changed');
|
||||
});
|
||||
|
||||
test('works it out again after a change of cards or reviews, but not when '
|
||||
'the same studysets are handed again', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final neko = await card(japanese);
|
||||
final seen = follow();
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 1);
|
||||
|
||||
await studysets.reread();
|
||||
await pumpEventQueue();
|
||||
expect(seen, hasLength(1));
|
||||
|
||||
await reviews.record(
|
||||
cardId: neko,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 0);
|
||||
|
||||
await card(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.cards, 2);
|
||||
});
|
||||
}
|
||||
|
|
@ -20,20 +20,23 @@ class FakeReviewGateway() implements ReviewGateway {
|
|||
Future<List<ReviewRow>> allReviews() async => [...rows];
|
||||
|
||||
@override
|
||||
Future<void> insert(Iterable<ReviewRow> reviews) async {
|
||||
Future<List<ReviewRow>> insert(Iterable<ReviewRow> reviews) async {
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
for (final review in reviews) {
|
||||
rows.add((
|
||||
final stored = [
|
||||
for (final review in reviews)
|
||||
(
|
||||
id: 'review-${(_nextId++).toString().padLeft(4, '0')}',
|
||||
cardId: review.cardId,
|
||||
rating: review.rating,
|
||||
reviewedAt: review.reviewedAt,
|
||||
durationMs: review.durationMs,
|
||||
mode: review.mode,
|
||||
));
|
||||
}
|
||||
),
|
||||
];
|
||||
rows.addAll(stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
||||
|
|
@ -279,6 +280,15 @@ Dependencies testDependencies({
|
|||
paths,
|
||||
pdfSources,
|
||||
);
|
||||
final studysetProgress = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfSources,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final cardDeletions = CardDeletions(cards);
|
||||
final pathDeletions = LearningPathDeletions(paths);
|
||||
|
|
@ -297,6 +307,7 @@ Dependencies testDependencies({
|
|||
pathProgress: pathProgress,
|
||||
pdfSources: pdfSources,
|
||||
reviews: reviews,
|
||||
studysetProgress: studysetProgress,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
|
|
@ -358,6 +369,7 @@ Dependencies testDependencies({
|
|||
await pathDeletions.dispose();
|
||||
await stepDeletions.dispose();
|
||||
await pdfDeletions.dispose();
|
||||
await studysetProgress.dispose();
|
||||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue