feat(app): offer to review first when reviews are due beyond the tags chosen

With tags narrowing a session, new cards of theirs to learn, and cards
of other tags due for review, the session asks first ("You have reviews
due"): Review first studies the reviews due across every tag, round
after round, the tags not applying until none is left (the session
settings say so meanwhile); Learn anyway learns the tags' new cards;
Cancel leaves. A new session asks again.

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-07 22:03:27 +02:00
commit 72e99ef790
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 475 additions and 49 deletions

View file

@ -479,6 +479,12 @@
"cardTileConflict": "Auf zwei Geräten geändert",
"studyClose": "Sitzung schließen",
"studyReviewsDueTitle": "Fällige Wiederholungen",
"studyReviewsDueBody": "{reviews, plural, =1{1 Karte ist} other{{reviews} Karten sind}} zur Wiederholung fällig. {news, plural, =1{Wenn du jetzt 1 neue Karte lernst} other{Wenn du jetzt {news} neue Karten lernst}}, erhöht das deine Wiederholungslast.",
"studyReviewsDueAllTagsNote": "„Zuerst wiederholen“ arbeitet den gesamten Rückstand über alle Tags hinweg ab – dein Tag-Filter greift erst wieder, wenn er abgebaut ist.",
"studyReviewFirst": "Zuerst wiederholen",
"studyLearnAnyway": "Trotzdem lernen",
"studySettingsTagFilterPausedForBacklog": "Diese Sitzung arbeitet deinen Wiederholungsrückstand über alle Tags hinweg ab, deshalb schränken diese Tags sie nicht ein. Ab der nächsten Sitzung greifen sie wieder.",
"studyBuryCard": "Bis morgen zurückstellen",
"studyCardBuried": "Karte bis morgen zurückgestellt",
"studyUndoLastRating": "Letzte Bewertung rückgängig machen",

View file

@ -528,6 +528,13 @@
"cardTileConflict": "Changed on two devices",
"studyClose": "Close session",
"studyReviewsDueTitle": "You have reviews due",
"studyReviewsDueBody": "{reviews, plural, =1{1 card is} other{{reviews} cards are}} due for review. Learning {news, plural, =1{1 new card} other{{news} new cards}} now will add to your review load.",
"@studyReviewsDueBody": {"placeholders": {"reviews": {"type": "int"}, "news": {"type": "int"}}},
"studyReviewsDueAllTagsNote": "“Review first” studies the whole backlog across every tag — your tag filter doesn't apply until it's clear.",
"studyReviewFirst": "Review first",
"studyLearnAnyway": "Learn anyway",
"studySettingsTagFilterPausedForBacklog": "This session is clearing your review backlog across every tag, so these tags aren't narrowing it. They apply again to your next session.",
"studyBuryCard": "Bury until tomorrow",
"studyCardBuried": "Card buried until tomorrow",
"studyUndoLastRating": "Undo last rating",

View file

@ -2890,6 +2890,42 @@ abstract class AppLocalizations {
/// **'Close session'**
String get studyClose;
/// No description provided for @studyReviewsDueTitle.
///
/// In en, this message translates to:
/// **'You have reviews due'**
String get studyReviewsDueTitle;
/// No description provided for @studyReviewsDueBody.
///
/// In en, this message translates to:
/// **'{reviews, plural, =1{1 card is} other{{reviews} cards are}} due for review. Learning {news, plural, =1{1 new card} other{{news} new cards}} now will add to your review load.'**
String studyReviewsDueBody(int reviews, int news);
/// No description provided for @studyReviewsDueAllTagsNote.
///
/// In en, this message translates to:
/// **'“Review first” studies the whole backlog across every tag — your tag filter doesn\'t apply until it\'s clear.'**
String get studyReviewsDueAllTagsNote;
/// No description provided for @studyReviewFirst.
///
/// In en, this message translates to:
/// **'Review first'**
String get studyReviewFirst;
/// No description provided for @studyLearnAnyway.
///
/// In en, this message translates to:
/// **'Learn anyway'**
String get studyLearnAnyway;
/// No description provided for @studySettingsTagFilterPausedForBacklog.
///
/// In en, this message translates to:
/// **'This session is clearing your review backlog across every tag, so these tags aren\'t narrowing it. They apply again to your next session.'**
String get studySettingsTagFilterPausedForBacklog;
/// No description provided for @studyBuryCard.
///
/// In en, this message translates to:

View file

@ -1670,6 +1670,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyClose => 'Sitzung schließen';
@override
String get studyReviewsDueTitle => 'Fällige Wiederholungen';
@override
String studyReviewsDueBody(int reviews, int news) {
String _temp0 = intl.Intl.pluralLogic(
reviews,
locale: localeName,
other: '$reviews Karten sind',
one: '1 Karte ist',
);
String _temp1 = intl.Intl.pluralLogic(
news,
locale: localeName,
other: 'Wenn du jetzt $news neue Karten lernst',
one: 'Wenn du jetzt 1 neue Karte lernst',
);
return '$_temp0 zur Wiederholung fällig. $_temp1, erhöht das deine Wiederholungslast.';
}
@override
String get studyReviewsDueAllTagsNote =>
'„Zuerst wiederholen“ arbeitet den gesamten Rückstand über alle Tags hinweg ab – dein Tag-Filter greift erst wieder, wenn er abgebaut ist.';
@override
String get studyReviewFirst => 'Zuerst wiederholen';
@override
String get studyLearnAnyway => 'Trotzdem lernen';
@override
String get studySettingsTagFilterPausedForBacklog =>
'Diese Sitzung arbeitet deinen Wiederholungsrückstand über alle Tags hinweg ab, deshalb schränken diese Tags sie nicht ein. Ab der nächsten Sitzung greifen sie wieder.';
@override
String get studyBuryCard => 'Bis morgen zurückstellen';

View file

@ -1659,6 +1659,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyClose => 'Close session';
@override
String get studyReviewsDueTitle => 'You have reviews due';
@override
String studyReviewsDueBody(int reviews, int news) {
String _temp0 = intl.Intl.pluralLogic(
reviews,
locale: localeName,
other: '$reviews cards are',
one: '1 card is',
);
String _temp1 = intl.Intl.pluralLogic(
news,
locale: localeName,
other: '$news new cards',
one: '1 new card',
);
return '$_temp0 due for review. Learning $_temp1 now will add to your review load.';
}
@override
String get studyReviewsDueAllTagsNote =>
'“Review first” studies the whole backlog across every tag — your tag filter doesn\'t apply until it\'s clear.';
@override
String get studyReviewFirst => 'Review first';
@override
String get studyLearnAnyway => 'Learn anyway';
@override
String get studySettingsTagFilterPausedForBacklog =>
'This session is clearing your review backlog across every tag, so these tags aren\'t narrowing it. They apply again to your next session.';
@override
String get studyBuryCard => 'Bury until tomorrow';

View file

@ -54,6 +54,14 @@ class StudySessionBloc(
on<StudySessionEventSettingsChanged>((event, emit) => _startSession(emit));
on<StudySessionEventContinued>((event, emit) => _startRound(emit));
on<StudySessionEventRetried>((event, emit) => _startSession(emit));
on<StudySessionEventReviewFirstChosen>((event, emit) {
_clearingBacklog = true;
return _startRound(emit);
});
on<StudySessionEventLearnAnywayChosen>((event, emit) {
_learnAnyway = true;
return _startRound(emit);
});
on<StudySessionEventAnswerShown>(
(event, emit) => _showAnswer(emit, shown: true),
);
@ -121,6 +129,14 @@ class StudySessionBloc(
/// The cards crammed in earlier rounds of this session.
final _crammed = <String>{};
/// Whether the session studies the reviews due across every tag first,
/// as the person chose, the tags chosen applying again once none is.
var _clearingBacklog = false;
/// Whether the person chose to learn new cards of the tags chosen while
/// reviews of others are due.
var _learnAnyway = false;
/// The ratings of the round that can be taken back, the last on top.
final _undoable = <_Undoable>[];
@ -144,6 +160,8 @@ class StudySessionBloc(
Future<void> _startSession(Emitter<StudySessionState> emit) {
_startedAt = clock.now().toUtc();
_lockedAt = null;
_clearingBacklog = false;
_learnAnyway = false;
_given.clear();
_done.clear();
return _startRound(emit);
@ -171,7 +189,7 @@ class StudySessionBloc(
_lastBuried = null;
_lastDeleted = null;
_cramIndex = 0;
final groups = await _groups();
final groups = await _groups(filtered: !_clearingBacklog);
_cardsById = {
for (final group in groups)
for (final card in group) card.id: card,
@ -183,6 +201,7 @@ class StudySessionBloc(
..._cardsById.keys,
}, startOfLocalDay(now));
if (_options.mode == LearnMode.cram) {
_clearingBacklog = false;
final all = [
for (final group in groups)
for (final card in group)
@ -202,7 +221,35 @@ class StudySessionBloc(
_groupIds = [
for (final group in groups) [for (final card in group) card.id],
];
final queue = _queue(schedules, now);
var queue = _queue(schedules, now);
bool isNew(String id) => schedules[id]?.isNew ?? true;
if (_clearingBacklog) {
queue = [
for (final id in queue)
if (!isNew(id)) id,
];
if (queue.isEmpty) {
// The backlog is clear: the tags chosen apply again.
_clearingBacklog = false;
await _readRound(emit);
return;
}
} else if (_tagFilterCount > 0 && !_learnAnyway) {
final news = queue.where(isNew).length;
if (news > 0) {
final reviews = await _reviewsDueAcrossTags(now);
if (reviews.any((id) => !_cardsById.containsKey(id))) {
emit(
StudySessionStateReviewsDue(
reviews: reviews.length,
news: news,
backFirst: _options.backFirst,
),
);
return;
}
}
}
_round = queue.take(_options.cardsPerRound).toList();
if (_round.isEmpty) {
final upcoming = [
@ -219,6 +266,35 @@ class StudySessionBloc(
await _showNext(emit, now);
}
/// The cards due for review in the studysets studied at [now], whatever
/// their tags.
Future<List<String>> _reviewsDueAcrossTags(DateTime now) async {
final all = await _studysets.all();
final studied = _focus?.topics == false
? [_studysetId]
: studiedIn(_studysetId, all);
final groups = [
for (final id in studied)
[
for (final card in await _cards.cardsOf(id))
if (!_deletions.pending.contains(card.id)) card.id,
],
];
final ids = [for (final group in groups) ...group];
final schedules = await _reviews.schedulesOf(ids);
return [
for (final id in studyQueue(
groups: groups,
options: _groupOptions,
schedules: schedules,
now: now,
startOfToday: startOfLocalDay(now),
setAside: await _reviews.buriedSince({...ids}, startOfLocalDay(now)),
))
if (!(schedules[id]?.isNew ?? true)) id,
];
}
/// The study queue of the round's studysets with their [schedules] at
/// [now].
List<String> _queue(Map<String, CardSchedule> schedules, DateTime now) =>
@ -239,8 +315,11 @@ class StudySessionBloc(
/// the tags chosen for the studyset applied: by the focus, if any, else
/// by the settings. Takes each one's study options along: its own, else
/// the settings' new cards a day.
Future<List<List<Card>>> _groups() async {
final tagged = await _cardsWithChosenTags();
Future<List<List<Card>>> _groups({bool filtered = true}) async {
final tagged = filtered ? await _cardsWithChosenTags() : null;
if (!filtered) {
_tagFilterCount = 0;
}
final all = await _studysets.all();
final studied = _focus?.topics == false
? [_studysetId]
@ -396,6 +475,7 @@ class StudySessionBloc(
tagFilterCount: _tagFilterCount,
backFirst: _options.backFirst,
locked: _lockedAt != null,
clearingBacklog: _clearingBacklog,
);
}
@ -745,6 +825,16 @@ final class const StudySessionEventStarted() extends StudySessionEvent;
/// The person's settings changed: a new round with them.
final class const StudySessionEventSettingsChanged() extends StudySessionEvent;
/// Asked about the reviews due beyond the tags chosen, study those first,
/// across every tag.
final class const StudySessionEventReviewFirstChosen()
extends StudySessionEvent;
/// Asked about the reviews due beyond the tags chosen, learn the new cards
/// of the tags chosen all the same.
final class const StudySessionEventLearnAnywayChosen()
extends StudySessionEvent;
/// The session couldn't be read: try again, from the start.
final class const StudySessionEventRetried() extends StudySessionEvent;
@ -857,6 +947,22 @@ final class const StudySessionStateLoading({
/// The session couldn't be read.
final class const StudySessionStateFailed() extends StudySessionState;
/// The tags chosen have new cards to learn while reviews of others are due:
/// review first, or learn anyway?
final class const StudySessionStateReviewsDue({
/// How many cards are due for review, whatever their tags.
required final int reviews,
/// How many new cards of the tags chosen the round would start.
required final int news,
/// Whether cards ask their back first.
final bool backFirst = false,
}) extends StudySessionState {
@override
List<Object?> get props => [reviews, news, backFirst];
}
/// Nothing to study now.
final class const StudySessionStateNothing({
/// When the next card is due; null without any studied card.
@ -931,6 +1037,10 @@ final class const StudySessionStateShowing({
/// Whether the screen is locked against stray touches.
final bool locked = false,
/// Whether the session studies the reviews due across every tag first,
/// the tags chosen not applying until then.
final bool clearingBacklog = false,
}) extends StudySessionState {
/// This state with the answer [shown] or hidden.
StudySessionStateShowing withAnswerShown(bool shown) =>
@ -961,6 +1071,7 @@ final class const StudySessionStateShowing({
nextDue: nextDue,
ratingFailed: ratingFailed ?? this.ratingFailed,
locked: locked,
clearingBacklog: clearingBacklog,
);
@override
@ -985,6 +1096,7 @@ final class const StudySessionStateShowing({
nextDue,
ratingFailed,
locked,
clearingBacklog,
];
}

View file

@ -37,7 +37,14 @@ class const StudyScreen({
}) extends StatelessWidget {
@override
Widget build(BuildContext context) =>
BlocBuilder<StudySessionBloc, StudySessionState>(
BlocListener<StudySessionBloc, StudySessionState>(
listenWhen: (previous, current) =>
current is StudySessionStateReviewsDue,
listener: (context, state) => _askReviewFirst(
context,
state as StudySessionStateReviewsDue,
),
child: BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) {
final header = _Header(
studysetId: studysetId,
@ -47,7 +54,9 @@ class const StudyScreen({
return PopScope(
canPop: state is! StudySessionStateShowing,
onPopInvokedWithResult: (didPop, _) async {
if (!didPop && await _confirmLeave(context) && context.mounted) {
if (!didPop &&
await _confirmLeave(context) &&
context.mounted) {
Navigator.of(context).pop();
}
},
@ -71,6 +80,9 @@ class const StudyScreen({
done,
),
StudySessionStateFailed() => const _Failed(),
// Behind the question about them.
StudySessionStateReviewsDue() =>
const SizedBox.shrink(),
_ => const Center(child: AppLoadingIndicator()),
},
),
@ -81,9 +93,65 @@ class const StudyScreen({
},
);
},
),
);
}
/// Asks, with reviews due beyond the tags chosen, whether to study those
/// first, to learn the new cards all the same, or to leave.
Future<void> _askReviewFirst(
BuildContext context,
StudySessionStateReviewsDue state,
) async {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final bloc = context.read<StudySessionBloc>();
final navigator = Navigator.of(context);
final choice = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
scrollable: true,
title: Text(l10n.studyReviewsDueTitle),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.studyReviewsDueBody(state.reviews, state.news)),
const SizedBox(height: 12),
Text(
l10n.studyReviewsDueAllTagsNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.studyReviewFirst),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.studyLearnAnyway),
),
],
),
);
switch (choice) {
case true:
bloc.add(const StudySessionEventReviewFirstChosen());
case false:
bloc.add(const StudySessionEventLearnAnywayChosen());
case null:
await navigator.maybePop();
}
}
/// Asks whether to leave the round under way, saying what is lost: true to
/// leave.
Future<bool> _confirmLeave(BuildContext context) async {
@ -135,6 +203,7 @@ class const _Header({
final theme = Theme.of(context);
final backFirst = switch (state) {
StudySessionStateFailed() => false,
StudySessionStateReviewsDue(:final backFirst) ||
StudySessionStateLoading(:final backFirst) ||
StudySessionStateNothing(:final backFirst) ||
StudySessionStateShowing(:final backFirst) ||
@ -215,6 +284,11 @@ class const _Header({
context,
studysetId: studysetId,
inProgress: state is StudySessionStateShowing,
clearingBacklog: switch (state) {
StudySessionStateShowing(:final clearingBacklog) =>
clearingBacklog,
_ => false,
},
)) {
bloc.add(const StudySessionEventSettingsChanged());
}

View file

@ -19,6 +19,9 @@ Future<bool> showStudySettingsSheet(
BuildContext context, {
required String studysetId,
bool inProgress = false,
/// Whether the session studies the reviews due across every tag first.
bool clearingBacklog = false,
}) async =>
await showAdaptiveSheet<bool>(
context,
@ -32,14 +35,19 @@ Future<bool> showStudySettingsSheet(
person.settings,
studysetId,
)..add(const StudySettingsEventStarted()),
child: _StudySettings(inProgress: inProgress),
child: _StudySettings(
inProgress: inProgress,
clearingBacklog: clearingBacklog,
),
);
},
) ??
false;
class const _StudySettings({required final bool inProgress})
extends StatelessWidget {
class const _StudySettings({
required final bool inProgress,
required final bool clearingBacklog,
}) extends StatelessWidget {
/// Saves, once the person agreed to start the round anew if one is under
/// way.
Future<void> _save(BuildContext context) async {
@ -123,6 +131,15 @@ class const _StudySettings({required final bool inProgress})
],
),
const SizedBox(height: 4),
if (clearingBacklog) ...[
Text(
l10n.studySettingsTagFilterPausedForBacklog,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
_ChosenTags(tags: loaded.tags, chosen: loaded.chosen),
const SizedBox(height: 16),
],

View file

@ -750,6 +750,72 @@ void main() {
});
});
group('With reviews due beyond the tags chosen', () {
setUp(() async {
final taberu = await cards.create('japanese', card('食べる'));
final verb = await tags.create('verb');
await tags.setTagsOf(taberu, {verb});
final neko = await cards.create('japanese', card('猫'));
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 60)),
duration: Duration.zero,
);
await settings.update(
(settings) =>
settings.withStudyTags('japanese', [(id: verb, name: 'verb')]),
);
});
test('asks first, before learning new cards of the tags', () async {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final asked = await until(
bloc,
(s) => s is StudySessionStateReviewsDue,
) as StudySessionStateReviewsDue;
expect(asked.reviews, 1);
expect(asked.news, 1);
});
});
test('reviewing first studies the reviews across every tag, then the '
'tags apply again', () async {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await until(bloc, (s) => s is StudySessionStateReviewsDue);
bloc.add(const StudySessionEventReviewFirstChosen());
final neko = await showing(bloc, '猫');
expect(neko.clearingBacklog, isTrue);
expect(neko.tagFilterCount, 0);
expect(neko.pills, hasLength(1));
bloc.add(const StudySessionEventRated(Rating.easy));
await until(bloc, (s) => s is StudySessionStateRoundDone);
bloc.add(const StudySessionEventContinued());
final taberu = await showing(bloc, '食べる');
expect(taberu.clearingBacklog, isFalse);
expect(taberu.tagFilterCount, 1);
});
});
test('learning anyway learns the new cards of the tags', () async {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await until(bloc, (s) => s is StudySessionStateReviewsDue);
bloc.add(const StudySessionEventLearnAnywayChosen());
expect((await showing(bloc, '食べる')).pills, hasLength(1));
});
});
});
group('With tags chosen to study by', () {
late String verb;

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.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_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
@ -17,6 +18,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
@ -65,6 +67,7 @@ void main() {
Future<void> study(
WidgetTester tester, {
GeoMapRepository? maps,
FakeReviewGateway? reviews,
double width = 1200,
}) async {
tester.view.physicalSize = Size(width, 1600);
@ -81,6 +84,7 @@ void main() {
cardGateway: gateway,
tagGateway: tagGateway,
settingsGateway: settingsGateway,
reviewGateway: reviews,
maps: maps,
),
location: RoutePaths.study('japanese'),
@ -298,6 +302,42 @@ void main() {
);
});
testWidgets('with tags chosen and reviews due beyond them, asks before '
'learning new cards; reviewing first studies those', (tester) async {
final verb = await tags.create('verb');
final taberu = await cards.create(
'japanese',
CardContent(front: paragraph('食べる'), back: paragraph('to eat')),
);
await tags.setTagsOf(taberu, {verb});
final neko = await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
final reviewGateway = FakeReviewGateway();
final reviews = ReviewRepository(reviewGateway);
addTearDown(reviews.dispose);
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: DateTime.now().toUtc().subtract(const Duration(days: 60)),
duration: Duration.zero,
);
final settings = SettingsRepository(settingsGateway);
addTearDown(settings.dispose);
await settings.update(
(each) => each.withStudyTags('japanese', [(id: verb, name: 'verb')]),
);
await study(tester, reviews: reviewGateway);
await tester.pumpAndSettle();
expect(find.text('You have reviews due'), findsOneWidget);
await tester.tap(find.text('Review first'));
await tester.pumpAndSettle();
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
});
testWidgets('says when there is nothing to study', (tester) async {
await study(tester);