perf(learn): a rated card gives way to the next one at once
Rating a card while studying waited for its review to be written, then for the card's schedule to be replayed, before the next card showed. StudySessionBloc now starts keeping the review and shows what comes next at once: the card's new place in the round is worked out from its reviews with the new one counted as if kept already (ReviewRepository.schedulesAsStudied's pending, each review counted once should it be kept by then). Should keeping it fail, the step leaves the session's Undo, the rating leaves its accuracy, and the card comes back where it stood, saying so (withRatingFailed), as a failed rating did before. An Undo of a rating still being kept waits for it and takes it back once kept; one that couldn't be kept has nothing to take back, and the Undo has brought the card back already. Whats-New: While studying, the next flashcard shows the moment you rate one. Whats-New-De: Beim Lernen kommt die nächste Karteikarte, sobald du eine bewertet hast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d6b2bec113
commit
4940230ef1
4 changed files with 235 additions and 57 deletions
|
|
@ -534,10 +534,14 @@ class StudySessionBloc(
|
||||||
|
|
||||||
/// The schedules of [ids], each in the direction its card is studied:
|
/// The schedules of [ids], each in the direction its card is studied:
|
||||||
/// written by hand, back first or front first.
|
/// written by hand, back first or front first.
|
||||||
Future<Map<String, CardSchedule>> _schedules(List<String> ids) =>
|
Future<Map<String, CardSchedule>> _schedules(
|
||||||
_reviews.schedulesAsStudied([
|
List<String> ids, {
|
||||||
for (final id in ids) _cardsById[id]!,
|
Iterable<Review> pending = const [],
|
||||||
], _options);
|
}) => _reviews.schedulesAsStudied(
|
||||||
|
[for (final id in ids) _cardsById[id]!],
|
||||||
|
_options,
|
||||||
|
pending: pending,
|
||||||
|
);
|
||||||
|
|
||||||
/// Shows the round's next card: the first of those to come, in the
|
/// Shows the round's next card: the first of those to come, in the
|
||||||
/// round's order, whether or not its time has come (decided 2026-10-16);
|
/// round's order, whether or not its time has come (decided 2026-10-16);
|
||||||
|
|
@ -712,57 +716,118 @@ class StudySessionBloc(
|
||||||
if (state case final StudySessionStateShowing showing) {
|
if (state case final StudySessionStateShowing showing) {
|
||||||
final card = showing.card;
|
final card = showing.card;
|
||||||
final now = clock.now().toUtc();
|
final now = clock.now().toUtc();
|
||||||
try {
|
final review = Review(
|
||||||
await _reviews.record(
|
id: '',
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
rating: event.rating,
|
rating: event.rating,
|
||||||
reviewedAt: now,
|
reviewedAt: now,
|
||||||
duration: now.difference(showing.shownAt),
|
duration: now.difference(showing.shownAt),
|
||||||
mode: _modeOf(card),
|
mode: _modeOf(card),
|
||||||
);
|
);
|
||||||
} on Exception catch (error) {
|
// The next card shows at once, while the rating is being kept
|
||||||
// The card stays, to be rated again.
|
// (optimistic state); should keeping it fail, the card comes back.
|
||||||
log('Keeping a rating failed', name: 'Study', error: error);
|
final step = _Rated(
|
||||||
emit(showing.withRatingFailed());
|
_standOf(card),
|
||||||
return;
|
at: now,
|
||||||
}
|
mode: review.mode,
|
||||||
_steps.add(_Rated(_standOf(card), at: now, mode: _modeOf(card)));
|
kept: _keep(review),
|
||||||
|
);
|
||||||
|
_steps.add(step);
|
||||||
_ratings[card.id] = event.rating;
|
_ratings[card.id] = event.rating;
|
||||||
_given.add(event.rating);
|
_given.add(event.rating);
|
||||||
if (_options.mode == LearnMode.cram) {
|
await _showAfterRating(emit, card, review);
|
||||||
_done.add(card.id);
|
if (!await step.kept) {
|
||||||
// The next card not rated yet, after this one and then from the
|
await _ratingNotKept(emit, step, event.rating);
|
||||||
// start; the round is done once every card has a rating.
|
}
|
||||||
final next = [
|
}
|
||||||
for (var i = 1; i <= _round.length; i++)
|
}
|
||||||
(_cramIndex + i) % _round.length,
|
|
||||||
].where((i) => !_ratings.containsKey(_round[i])).firstOrNull;
|
/// Keeps [review], saying whether that went through.
|
||||||
if (next == null) {
|
Future<bool> _keep(Review review) async {
|
||||||
emit(await _roundDone());
|
try {
|
||||||
return;
|
await _reviews.record(
|
||||||
}
|
cardId: review.cardId,
|
||||||
_cramIndex = next;
|
rating: review.rating,
|
||||||
emit(_showing(_round[next], now, schedule: null));
|
reviewedAt: review.reviewedAt,
|
||||||
|
duration: review.duration,
|
||||||
|
mode: review.mode,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Keeping a rating failed', name: 'Study', error: error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows what comes after [card] was rated as [review] says, which may
|
||||||
|
/// still be being kept.
|
||||||
|
Future<void> _showAfterRating(
|
||||||
|
Emitter<StudySessionState> emit,
|
||||||
|
Card card,
|
||||||
|
Review review,
|
||||||
|
) async {
|
||||||
|
final now = review.reviewedAt;
|
||||||
|
if (_options.mode == LearnMode.cram) {
|
||||||
|
_done.add(card.id);
|
||||||
|
// The next card not rated yet, after this one and then from the
|
||||||
|
// start; the round is done once every card has a rating.
|
||||||
|
final next = [
|
||||||
|
for (var i = 1; i <= _round.length; i++)
|
||||||
|
(_cramIndex + i) % _round.length,
|
||||||
|
].where((i) => !_ratings.containsKey(_round[i])).firstOrNull;
|
||||||
|
if (next == null) {
|
||||||
|
emit(await _roundDone());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final schedule = (await _schedules([card.id]))[card.id]!;
|
_cramIndex = next;
|
||||||
// The round goes in an order (decided 2026-10-16): the cards done,
|
emit(_showing(_round[next], now, schedule: null));
|
||||||
// then the one shown, then those to come. A day or more away, the
|
return;
|
||||||
// card is done for this round, the last of those done; else it goes
|
}
|
||||||
// back among those to come, where the rating puts it.
|
final schedule = (await _schedules(
|
||||||
_round.remove(card.id);
|
[card.id],
|
||||||
final firstToCome = _round.where(_graduated.contains).length;
|
pending: [review],
|
||||||
if (!schedule.due!.isBefore(now.add(_roundsDay))) {
|
))[card.id]!;
|
||||||
_graduated.add(card.id);
|
// The round goes in an order (decided 2026-10-16): the cards done,
|
||||||
_done.add(card.id);
|
// then the one shown, then those to come. A day or more away, the
|
||||||
_round.insert(firstToCome, card.id);
|
// card is done for this round, the last of those done; else it goes
|
||||||
} else {
|
// back among those to come, where the rating puts it.
|
||||||
_round.insert(
|
_round.remove(card.id);
|
||||||
firstToCome + cardsBefore(event.rating, _round.length - firstToCome),
|
final firstToCome = _round.where(_graduated.contains).length;
|
||||||
card.id,
|
if (!schedule.due!.isBefore(now.add(_roundsDay))) {
|
||||||
);
|
_graduated.add(card.id);
|
||||||
}
|
_done.add(card.id);
|
||||||
await _showNext(emit, now);
|
_round.insert(firstToCome, card.id);
|
||||||
|
} else {
|
||||||
|
_round.insert(
|
||||||
|
firstToCome + cardsBefore(review.rating, _round.length - firstToCome),
|
||||||
|
card.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _showNext(emit, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the session back as it was before [step], whose rating [rating]
|
||||||
|
/// couldn't be kept: the card shows again, to be rated again, saying so.
|
||||||
|
/// Nothing to do once the step was taken back (Undo brought the card
|
||||||
|
/// back already).
|
||||||
|
Future<void> _ratingNotKept(
|
||||||
|
Emitter<StudySessionState> emit,
|
||||||
|
_Rated step,
|
||||||
|
Rating rating,
|
||||||
|
) async {
|
||||||
|
if (isClosed || !_steps.remove(step)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step.before.session == _sessionNumber) {
|
||||||
|
_given.remove(rating);
|
||||||
|
}
|
||||||
|
// While another round is being read, it reads the card as unrated.
|
||||||
|
if (!_offersUndo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _bringBack(emit, step.before, show: true);
|
||||||
|
if (state case final StudySessionStateShowing showing) {
|
||||||
|
emit(showing.withRatingFailed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -894,12 +959,16 @@ class StudySessionBloc(
|
||||||
Future<void> _takeBack(_Step step) async {
|
Future<void> _takeBack(_Step step) async {
|
||||||
final id = step.before.card.id;
|
final id = step.before.card.id;
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case _Rated(:final at, :final mode):
|
case _Rated(:final at, :final mode, :final kept):
|
||||||
await _reviews.undo(cardId: id, reviewedAt: at, mode: mode);
|
|
||||||
// Ratings are taken back last first: it was the last given.
|
// Ratings are taken back last first: it was the last given.
|
||||||
if (step.before.session == _sessionNumber && _given.isNotEmpty) {
|
if (step.before.session == _sessionNumber && _given.isNotEmpty) {
|
||||||
_given.removeLast();
|
_given.removeLast();
|
||||||
}
|
}
|
||||||
|
// One still being kept is taken back once it is; one that couldn't
|
||||||
|
// be has nothing to take back.
|
||||||
|
if (await kept) {
|
||||||
|
await _reviews.undo(cardId: id, reviewedAt: at, mode: mode);
|
||||||
|
}
|
||||||
case _Buried():
|
case _Buried():
|
||||||
await _cards.unbury(id);
|
await _cards.unbury(id);
|
||||||
_setAside.remove(id);
|
_setAside.remove(id);
|
||||||
|
|
@ -1437,11 +1506,13 @@ typedef _Stand = ({
|
||||||
/// [before].
|
/// [before].
|
||||||
sealed class const _Step(final _Stand before);
|
sealed class const _Step(final _Stand before);
|
||||||
|
|
||||||
/// The card was rated [at], studied as [mode].
|
/// The card was rated [at], studied as [mode]; [kept] says whether the
|
||||||
|
/// review was kept, once it is known.
|
||||||
final class const _Rated(
|
final class const _Rated(
|
||||||
super.before, {
|
super.before, {
|
||||||
required final DateTime at,
|
required final DateTime at,
|
||||||
required final StudyMode mode,
|
required final StudyMode mode,
|
||||||
|
required final Future<bool> kept,
|
||||||
}) extends _Step;
|
}) extends _Step;
|
||||||
|
|
||||||
/// The card was buried: set aside for the day.
|
/// The card was buried: set aside for the day.
|
||||||
|
|
|
||||||
|
|
@ -150,11 +150,14 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
||||||
|
|
||||||
/// The schedules of [cards], each on the one it is studied on under
|
/// The schedules of [cards], each on the one it is studied on under
|
||||||
/// [options] ([scheduleModeOf]: written by hand, back first or front
|
/// [options] ([scheduleModeOf]: written by hand, back first or front
|
||||||
/// first), as a session would study them.
|
/// first), as a session would study them; with [pending], reviews being
|
||||||
|
/// recorded still, as if they were kept already (a session shows the
|
||||||
|
/// next card before its rating is kept), each counted once.
|
||||||
Future<Map<String, CardSchedule>> schedulesAsStudied(
|
Future<Map<String, CardSchedule>> schedulesAsStudied(
|
||||||
List<Card> cards,
|
List<Card> cards,
|
||||||
StudyOptions options,
|
StudyOptions options, {
|
||||||
) async {
|
Iterable<Review> pending = const [],
|
||||||
|
}) async {
|
||||||
final byMode = <StudyMode, List<String>>{};
|
final byMode = <StudyMode, List<String>>{};
|
||||||
for (final card in cards) {
|
for (final card in cards) {
|
||||||
(byMode[scheduleModeOf(card.content, options)] ??= []).add(card.id);
|
(byMode[scheduleModeOf(card.content, options)] ??= []).add(card.id);
|
||||||
|
|
@ -163,6 +166,16 @@ class ReviewRepository(final ReviewGateway _gateway) {
|
||||||
return const {};
|
return const {};
|
||||||
}
|
}
|
||||||
final reviews = await reviewsOf({for (final card in cards) card.id});
|
final reviews = await reviewsOf({for (final card in cards) card.id});
|
||||||
|
for (final review in pending) {
|
||||||
|
if (!reviews.any(
|
||||||
|
(kept) =>
|
||||||
|
kept.cardId == review.cardId &&
|
||||||
|
kept.mode == review.mode &&
|
||||||
|
kept.reviewedAt == review.reviewedAt,
|
||||||
|
)) {
|
||||||
|
reviews.add(review);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
for (final MapEntry(key: mode, value: ids) in byMode.entries)
|
for (final MapEntry(key: mode, value: ids) in byMode.entries)
|
||||||
...schedulesFrom(reviews, ids, mode: mode),
|
...schedulesFrom(reviews, ids, mode: mode),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
|
@ -528,6 +529,91 @@ void main() {
|
||||||
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
|
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a rating shows the next card at once, before it is kept', () async {
|
||||||
|
await cards.create('japanese', card('猫'));
|
||||||
|
await cards.create('japanese', card('犬'));
|
||||||
|
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
final bloc = session();
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await showing(bloc, '猫');
|
||||||
|
final hold = reviewGateway.holdWrites = Completer<void>();
|
||||||
|
|
||||||
|
bloc.add(const StudySessionEventRated(Rating.easy));
|
||||||
|
final dog = await showing(bloc, '犬');
|
||||||
|
expect(reviewGateway.rows, isEmpty, reason: 'not kept yet');
|
||||||
|
expect(dog.canUndo, isTrue);
|
||||||
|
|
||||||
|
hold.complete();
|
||||||
|
await eventually(() => reviewGateway.rows.length == 1);
|
||||||
|
expect(reviewGateway.rows.single.rating, Rating.easy.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rating that can't be kept once the next card shows brings "
|
||||||
|
'the card back, saying so, with nothing to undo', () async {
|
||||||
|
await cards.create('japanese', card('猫'));
|
||||||
|
await cards.create('japanese', card('犬'));
|
||||||
|
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
final bloc = session();
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await showing(bloc, '猫');
|
||||||
|
final hold = reviewGateway.holdWrites = Completer<void>();
|
||||||
|
reviewGateway.failWrites = Exception('disk');
|
||||||
|
|
||||||
|
bloc.add(const StudySessionEventRated(Rating.easy));
|
||||||
|
await showing(bloc, '犬');
|
||||||
|
hold.complete();
|
||||||
|
|
||||||
|
final failed = await until(
|
||||||
|
bloc,
|
||||||
|
(s) => s is StudySessionStateShowing && s.ratingFailed,
|
||||||
|
) as StudySessionStateShowing;
|
||||||
|
expect(failed.card.content.front, text('猫'));
|
||||||
|
expect(failed.canUndo, isFalse);
|
||||||
|
expect(failed.pills.map((pill) => pill.state), [
|
||||||
|
PillState.active,
|
||||||
|
PillState.pending,
|
||||||
|
]);
|
||||||
|
|
||||||
|
reviewGateway
|
||||||
|
..holdWrites = null
|
||||||
|
..failWrites = null;
|
||||||
|
bloc.add(const StudySessionEventRated(Rating.easy));
|
||||||
|
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
|
||||||
|
await eventually(() => reviewGateway.rows.length == 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Undo while a rating is still being kept takes it back once it '
|
||||||
|
'is', () async {
|
||||||
|
await cards.create('japanese', card('猫'));
|
||||||
|
await cards.create('japanese', card('犬'));
|
||||||
|
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
final bloc = session();
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await showing(bloc, '猫');
|
||||||
|
final hold = reviewGateway.holdWrites = Completer<void>();
|
||||||
|
|
||||||
|
bloc.add(const StudySessionEventRated(Rating.easy));
|
||||||
|
await showing(bloc, '犬');
|
||||||
|
bloc.add(const StudySessionEventUndone());
|
||||||
|
hold.complete();
|
||||||
|
|
||||||
|
final neko = await showing(bloc, '猫');
|
||||||
|
expect(neko.ratingFailed, isFalse);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(reviewGateway.rows, isEmpty);
|
||||||
|
expect(bloc.state, isA<StudySessionStateShowing>());
|
||||||
|
expect(
|
||||||
|
(bloc.state as StudySessionStateShowing).card.content.front,
|
||||||
|
text('猫'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a round holds at most the cards per round', () async {
|
test('a round holds at most the cards per round', () async {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
||||||
|
|
||||||
/// A [ReviewGateway] in memory; ids sort by creation, as UUID v7 ids do.
|
/// A [ReviewGateway] in memory; ids sort by creation, as UUID v7 ids do.
|
||||||
|
|
@ -10,6 +12,9 @@ class FakeReviewGateway() implements ReviewGateway {
|
||||||
/// When set, storing a review fails with it.
|
/// When set, storing a review fails with it.
|
||||||
Exception? failWrites;
|
Exception? failWrites;
|
||||||
|
|
||||||
|
/// When set, storing a review waits for it to complete.
|
||||||
|
Completer<void>? holdWrites;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) async => [
|
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) async => [
|
||||||
for (final row in rows)
|
for (final row in rows)
|
||||||
|
|
@ -21,6 +26,9 @@ class FakeReviewGateway() implements ReviewGateway {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ReviewRow>> insert(Iterable<ReviewRow> reviews) async {
|
Future<List<ReviewRow>> insert(Iterable<ReviewRow> reviews) async {
|
||||||
|
if (holdWrites case final hold?) {
|
||||||
|
await hold.future;
|
||||||
|
}
|
||||||
if (failWrites case final error?) {
|
if (failWrites case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue