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:
Tizian 「ティツィアーン」 2026-10-17 01:21:16 +02:00
commit 301751607d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 836 additions and 258 deletions

View file

@ -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();

View file

@ -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,

View file

@ -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,31 +119,57 @@ 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 card in rows) card.id,
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 [
for (final row in rows)
if (mergeRevisions(revisionsByCard[row.id] ?? const [])
case final merged?)
Card(
id: row.id,
position: row.position,
buriedAt: row.buriedAt,
content: merged.content,
heads: merged.heads,
conflicts: merged.conflicts,
),
];
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?)
Card(
id: row.id,
position: row.position,
buriedAt: row.buriedAt,
content: merged.content,
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.

View file

@ -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.

View file

@ -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 => [
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
];
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,16 +224,18 @@ class ReviewRepository(final ReviewGateway _gateway) {
required Duration duration,
StudyMode mode = StudyMode.review,
}) async {
await _gateway.insert([
(
id: '',
cardId: cardId,
rating: rating.value,
reviewedAt: reviewedAt.toUtc(),
durationMs: duration.inMilliseconds,
mode: mode.value,
),
]);
_kept(
await _gateway.insert([
(
id: '',
cardId: cardId,
rating: rating.value,
reviewedAt: reviewedAt.toUtc(),
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);
}

View file

@ -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

View file

@ -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

View file

@ -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;
}
}

View file

@ -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.

View file

@ -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()),
),

View file

@ -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),

View file

@ -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']);

View file

@ -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;

View file

@ -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(

View file

@ -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);
});

View file

@ -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 {

View file

@ -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);
});
}

View file

@ -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((
id: 'review-${(_nextId++).toString().padLeft(4, '0')}',
cardId: review.cardId,
rating: review.rating,
reviewedAt: review.reviewedAt,
durationMs: review.durationMs,
mode: review.mode,
));
}
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

View file

@ -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();