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
19 changed files with 836 additions and 258 deletions
|
|
@ -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,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.
|
||||
|
|
|
|||
|
|
@ -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 => [
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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