perf(studyset): read exam plans only after changes that matter, once they settle

Every studyset screen holds an exam cubit, those stacked beneath the one
in view and beneath a study session too, and each read the plans again
on every change of cards, reviews, paths, their progress, PDFs or tags:
with every rating, each of them decoded every card of its studyset, some
8,000 in a language's, and its progress with an exam chosen too.

Now the plans are read again only when studysets, cards or tags change,
and the progress alone on reviews, paths, their progress and PDFs, only
while an exam is chosen. Reading waits until changes have settled for
2 s (cancelled when the cubit closes), and a burst still reads once.
Studysets and tags handed out again unchanged are no change, and a
change of the studyset itself (the choice, its author's plans) shows at
once.

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-14 17:49:28 +02:00
commit 87b200e3eb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 271 additions and 8 deletions

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
@ -13,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// The exams a studyset plans for and the one the person prepares for: /// The exams a studyset plans for and the one the person prepares for:
/// [plans] (its author's, then those offered from level tags; null until /// [plans] (its author's, then those offered from level tags; null until
@ -33,6 +35,15 @@ final class const ExamState({
/// how far the person has come with the plan chosen as its cards, reviews, /// how far the person has come with the plan chosen as its cards, reviews,
/// paths, their progress, PDFs and tags change; writes the choice and the /// paths, their progress, PDFs and tags change; writes the choice and the
/// author's plans into the studyset. /// author's plans into the studyset.
///
/// Every screen of a studyset holds one, those beneath the one in view and
/// beneath a study session too, and a reading decodes every card the
/// studyset studies (thousands in a language's), so it reads only what a
/// change can have changed, once changes settle ([settle]): the plans when
/// studysets, cards or tags change, the progress alone when reviews, paths,
/// their progress or PDFs do, and that only while an exam is chosen. A
/// change of the studyset itself (the choice, its author's plans) shows at
/// once.
class ExamCubit({ class ExamCubit({
required final StudysetRepository _studysets, required final StudysetRepository _studysets,
required final CardRepository _cards, required final CardRepository _cards,
@ -45,22 +56,23 @@ class ExamCubit({
}) extends Cubit<ExamState> { }) extends Cubit<ExamState> {
this : super(const ExamState()) { this : super(const ExamState()) {
_subscriptions = [ _subscriptions = [
_studysets.watchAll().listen((all) { _studysets.watchAll().listen(_studysetsChanged, onError: (Object _) {}),
_studyset = all.where((each) => each.id == _studysetId).firstOrNull; _cards.changes.listen((_) => _changed(plans: true)),
_refresh(); _tags.watch().listen(_taggingChanged, onError: (Object _) {}),
}, onError: (Object _) {}),
for (final changes in [ for (final changes in [
_cards.changes,
_reviews.changes, _reviews.changes,
_paths.changes, _paths.changes,
_pathProgress.changes, _pathProgress.changes,
_pdfs.changes, _pdfs.changes,
]) ])
changes.listen((_) => _refresh()), changes.listen((_) => _changed(plans: false)),
_tags.watch().listen((_) => _refresh(), onError: (Object _) {}),
]; ];
} }
/// How long reading waits after the last change: a session's ratings, an
/// import's cards and a sync's changes come in bursts.
static const settle = Duration(seconds: 2);
late final _coverage = ExamCoverage( late final _coverage = ExamCoverage(
studysets: _studysets, studysets: _studysets,
cards: _cards, cards: _cards,
@ -69,11 +81,61 @@ class ExamCubit({
late final List<StreamSubscription<Object?>> _subscriptions; late final List<StreamSubscription<Object?>> _subscriptions;
Studyset? _studyset; Studyset? _studyset;
/// The studysets and the tagging as last seen: watching them anew
/// anywhere hands every listener the same again, which changes nothing.
List<Studyset>? _all;
Tagging? _tagging;
/// Whether the plans must be read again, rather than only the progress.
var _plansStale = true;
/// The reading waiting for changes to settle.
Timer? _waiting;
/// Whether a reading runs, and whether another was asked for meanwhile: /// Whether a reading runs, and whether another was asked for meanwhile:
/// a burst of changes reads once more, not once each. /// a burst of changes reads once more, not once each.
var _reading = false; var _reading = false;
var _readAgain = false; var _readAgain = false;
void _studysetsChanged(List<Studyset> all) {
if (listEquals(all, _all)) {
return;
}
_all = all;
final before = _studyset;
_studyset = all.where((each) => each.id == _studysetId).firstOrNull;
// Their tree decides whose cards' tags the plans come from.
_plansStale = true;
if (before == null || before.content != _studyset?.content) {
_waiting?.cancel();
_refresh();
} else {
_changed(plans: true);
}
}
void _taggingChanged(Tagging tagging) {
final before = _tagging;
_tagging = tagging;
// The first is no change: the first reading, which the studysets'
// first list starts, reads the tagging after it.
if (before != null && before != tagging) {
_changed(plans: true);
}
}
/// Reads again once changes settle: the plans too when [plans]; the
/// progress alone otherwise, which nothing shows without an exam chosen.
void _changed({required bool plans}) {
if (plans) {
_plansStale = true;
} else if (_studyset?.content.exam == null) {
return;
}
_waiting?.cancel();
_waiting = Timer(settle, _refresh);
}
void _refresh() { void _refresh() {
if (_reading) { if (_reading) {
_readAgain = true; _readAgain = true;
@ -88,7 +150,9 @@ class ExamCubit({
do { do {
_readAgain = false; _readAgain = false;
final choice = _studyset?.content.exam; final choice = _studyset?.content.exam;
final plans = await _coverage.plansOf(_studysetId); final known = _plansStale ? null : state.plans;
_plansStale = false;
final plans = known ?? await _coverage.plansOf(_studysetId);
final plan = switch (choice) { final plan = switch (choice) {
final choice? => final choice? =>
plans.where((plan) => plan.id == choice.plan).firstOrNull, plans.where((plan) => plan.id == choice.plan).firstOrNull,
@ -117,6 +181,8 @@ class ExamCubit({
); );
} while (_readAgain && !isClosed); } while (_readAgain && !isClosed);
} on Object catch (error) { } on Object catch (error) {
// The next change reads them again.
_plansStale = true;
log('Reading the exam plans failed', name: 'Exam', error: error); log('Reading the exam plans failed', name: 'Exam', error: error);
} finally { } finally {
_reading = false; _reading = false;
@ -188,6 +254,7 @@ class ExamCubit({
@override @override
Future<void> close() async { Future<void> close() async {
_waiting?.cancel();
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
await subscription.cancel(); await subscription.cancel();
} }

View file

@ -0,0 +1,193 @@
import 'dart:async';
import 'package:clock/clock.dart';
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';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.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_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';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.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_path_answer_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_review_gateway.dart';
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 {
int reads = 0;
@override
Future<List<CardRow>> cardsOf(String studysetId) {
reads++;
return super.cardsOf(studysetId);
}
}
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 StudysetRepository studysets;
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late String japanese;
late String neko;
late String inu;
late ExamCubit cubit;
/// Runs [steps] on the fake clock until nothing waits but timers.
void run(FakeAsync async, Future<void> Function() steps) {
unawaited(steps());
async.flushMicrotasks();
}
/// A Japanese studyset preparing for [choice], holding 猫 and 犬 tagged
/// 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();
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(cardGateway);
final paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
pathChanges: paths.changes,
);
reviews = ReviewRepository(FakeReviewGateway());
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
run(async, () async {
japanese = await studysets.create(
StudysetContent.titled('Japanese').withExam(choice),
);
Future<String> card(String front) => cards.create(
japanese,
CardContent(
front: RichContent.plain(front),
back: RichContent.plain('back'),
),
);
neko = await card('猫');
inu = await card('犬');
await card('本');
final n5 = await tags.create('JLPT-N5');
await tags.setTagsOf(neko, {n5});
await tags.setTagsOf(inu, {n5});
});
cubit = ExamCubit(
studysets: studysets,
cards: cards,
tags: tags,
reviews: reviews,
paths: paths,
pathProgress: PathProgressRepository(FakePathAnswerGateway()),
pdfs: PdfSourceRepository(FakePdfSourceGateway(), assets),
studysetId: japanese,
);
async.elapse(ExamCubit.settle * 2);
}
void review(FakeAsync async, String cardId) => run(
async,
() => reviews.record(
cardId: cardId,
rating: Rating.good,
reviewedAt: clock.now(),
duration: const Duration(seconds: 5),
),
);
test('reads no cards 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;
review(async, neko);
async.elapse(ExamCubit.settle * 2);
expect(cardGateway.reads, reads);
unawaited(cubit.close());
async.flushMicrotasks();
}, initialTime: today);
});
test('shows the exam chosen at once; with one, reads the progress alone '
'once a burst of ratings has settled, and nothing once closed', () {
fakeAsync((async) {
start(async);
run(async, () => cubit.choose(exam.plan, exam.date));
expect(cubit.state.choice, exam);
expect(cubit.state.progress?.newCards, 2);
final reads = cardGateway.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');
async.elapse(ExamCubit.settle);
// One studyset's cards read once: the progress, not the plans.
expect(cardGateway.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);
}, initialTime: today);
});
test('reads the plans again once a change of tags has settled', () {
fakeAsync((async) {
start(async, choice: exam);
run(async, () async {
await tags.setTagsOf(inu, {await tags.create('JLPT-N4')});
});
async.elapse(ExamCubit.settle);
expect(cubit.state.plans, const [
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
ExamPlan(
id: 'auto:jlpt:2',
name: 'JLPT N4',
tags: ['JLPT-N5', 'JLPT-N4'],
),
]);
expect(cubit.state.progress?.cards, 1);
unawaited(cubit.close());
async.flushMicrotasks();
}, initialTime: today);
});
}

View file

@ -28,6 +28,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart'; import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
@ -2034,6 +2035,8 @@ void main() {
final n5 = await person.tags.create('JLPT-N5'); final n5 = await person.tags.create('JLPT-N5');
await person.tags.setTagsOf(neko, {n5}); await person.tags.setTagsOf(neko, {n5});
await person.tags.setTagsOf(inu, {n5}); await person.tags.setTagsOf(inu, {n5});
// The plans are read again once the changes have settled.
await tester.pump(ExamCubit.settle);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await openPart(tester, 'Study plan'); await openPart(tester, 'Study plan');