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:
parent
6893db7da8
commit
72e99ef790
10 changed files with 475 additions and 49 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue