feat(app): lock the study screen, and undo the last rating

- Lock screen (header): a light scrim takes every touch; a tap shows
  Unlock for three seconds. Time locked doesn't count to the session's,
  and a running Pomodoro timer pauses, going on once unlocked (also
  when the session is left locked).
- Undo last rating (header): takes back one rating at a time within the
  round, its review deleted from the log, the round as it was; the card
  shows again from its question. Starting a round anew, or deleting a
  card, clears what can be undone.
- The review log deletes entries of any kind made at a moment (resets
  and ratings alike).

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 21:06:58 +02:00
commit aad6110da3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 515 additions and 87 deletions

View file

@ -440,6 +440,10 @@
"cardTileConflict": "Auf zwei Geräten geändert",
"studyClose": "Sitzung schließen",
"studyUndoLastRating": "Letzte Bewertung rückgängig machen",
"studyLockScreen": "Bildschirm sperren",
"studyUnlock": "Entsperren",
"studyTapToUnlock": "Zum Entsperren tippen",
"studyLoadFailed": "Diese Sitzung konnte nicht gelesen werden.",
"studyRatingFailed": "Deine Bewertung konnte nicht gespeichert werden. Bewerte die Karte noch einmal.",
"studyRetry": "Erneut versuchen",

View file

@ -487,6 +487,10 @@
"cardTileConflict": "Changed on two devices",
"studyClose": "Close session",
"studyUndoLastRating": "Undo last rating",
"studyLockScreen": "Lock screen",
"studyUnlock": "Unlock",
"studyTapToUnlock": "Tap to unlock",
"studyLoadFailed": "Couldn't read this session.",
"studyRatingFailed": "Couldn't keep your rating. Rate the card again.",
"studyRetry": "Retry",

View file

@ -2656,6 +2656,30 @@ abstract class AppLocalizations {
/// **'Close session'**
String get studyClose;
/// No description provided for @studyUndoLastRating.
///
/// In en, this message translates to:
/// **'Undo last rating'**
String get studyUndoLastRating;
/// No description provided for @studyLockScreen.
///
/// In en, this message translates to:
/// **'Lock screen'**
String get studyLockScreen;
/// No description provided for @studyUnlock.
///
/// In en, this message translates to:
/// **'Unlock'**
String get studyUnlock;
/// No description provided for @studyTapToUnlock.
///
/// In en, this message translates to:
/// **'Tap to unlock'**
String get studyTapToUnlock;
/// No description provided for @studyLoadFailed.
///
/// In en, this message translates to:

View file

@ -1539,6 +1539,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyClose => 'Sitzung schließen';
@override
String get studyUndoLastRating => 'Letzte Bewertung rückgängig machen';
@override
String get studyLockScreen => 'Bildschirm sperren';
@override
String get studyUnlock => 'Entsperren';
@override
String get studyTapToUnlock => 'Zum Entsperren tippen';
@override
String get studyLoadFailed => 'Diese Sitzung konnte nicht gelesen werden.';

View file

@ -1528,6 +1528,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyClose => 'Close session';
@override
String get studyUndoLastRating => 'Undo last rating';
@override
String get studyLockScreen => 'Lock screen';
@override
String get studyUnlock => 'Unlock';
@override
String get studyTapToUnlock => 'Tap to unlock';
@override
String get studyLoadFailed => 'Couldn\'t read this session.';

View file

@ -60,6 +60,8 @@ class StudySessionBloc(
on<StudySessionEventRated>(_onRated);
on<StudySessionEventMoved>(_onMoved);
on<StudySessionEventTagsChanged>(_onTagsChanged);
on<StudySessionEventLockToggled>(_onLockToggled);
on<StudySessionEventUndone>(_onUndone);
on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventBackFirstToggled>((event, emit) async {
@ -100,9 +102,16 @@ class StudySessionBloc(
/// The cards crammed in earlier rounds of this session.
final _crammed = <String>{};
/// The ratings of the round that can be taken back, the last on top.
final _undoable = <_Undoable>[];
// How the session goes, over all its rounds: when it began, every rating
// given, and the cards done.
var _startedAt = DateTime.utc(0);
/// Since when the screen is locked; null while it isn't. Time locked
/// doesn't count to the session's.
DateTime? _lockedAt;
final _given = <Rating>[];
final _done = <String>{};
@ -115,6 +124,7 @@ class StudySessionBloc(
/// Begins the session anew: from now, with nothing rated yet.
Future<void> _startSession(Emitter<StudySessionState> emit) {
_startedAt = clock.now().toUtc();
_lockedAt = null;
_given.clear();
_done.clear();
return _startRound(emit);
@ -138,6 +148,7 @@ class StudySessionBloc(
}
_ratings.clear();
_graduated.clear();
_undoable.clear();
_cramIndex = 0;
final groups = await _groups();
_cardsById = {
@ -343,8 +354,10 @@ class StudySessionBloc(
// last, and skipping is always there.
canGoBack: _options.mode == LearnMode.cram && _round.length > 1,
canGoForward: _options.mode == LearnMode.cram,
canUndo: _undoable.isNotEmpty,
tagFilterCount: _tagFilterCount,
backFirst: _options.backFirst,
locked: _lockedAt != null,
);
}
@ -435,6 +448,18 @@ class StudySessionBloc(
emit(showing.withRatingFailed());
return;
}
_undoable.add(
_Undoable(
showing: showing,
rating: (cardId: card.id, at: now, mode: _modeOf(card)),
round: [..._round],
ratings: {..._ratings},
graduated: {..._graduated},
done: {..._done},
given: _given.length,
cramIndex: _cramIndex,
),
);
_ratings[card.id] = event.rating;
_given.add(event.rating);
if (_options.mode == LearnMode.cram) {
@ -504,6 +529,55 @@ class StudySessionBloc(
}
}
Future<void> _onUndone(
StudySessionEventUndone event,
Emitter<StudySessionState> emit,
) async {
if (state is! StudySessionStateShowing || _undoable.isEmpty) {
return;
}
final last = _undoable.removeLast();
final (:cardId, :at, :mode) = last.rating;
await _reviews.undo(cardId: cardId, reviewedAt: at, mode: mode);
_round = last.round;
_ratings
..clear()
..addAll(last.ratings);
_graduated
..clear()
..addAll(last.graduated);
_done
..clear()
..addAll(last.done);
_given.removeRange(last.given, _given.length);
_cramIndex = last.cramIndex;
// As it was, but its time to answer starts anew.
final now = clock.now().toUtc();
emit(
_showing(
cardId,
now,
schedule: last.showing.schedule,
),
);
}
void _onLockToggled(
StudySessionEventLockToggled event,
Emitter<StudySessionState> emit,
) {
if (state case final StudySessionStateShowing showing) {
final now = clock.now().toUtc();
if (_lockedAt case final lockedAt?) {
_startedAt = _startedAt.add(now.difference(lockedAt));
_lockedAt = null;
} else {
_lockedAt = now;
}
_reshow(emit, showing);
}
}
/// Shows the card on screen again as it now is, its answer as it was.
void _reshow(
Emitter<StudySessionState> emit,
@ -518,6 +592,8 @@ class StudySessionBloc(
/// Takes the card [id] out of the session, and shows the next.
Future<void> _leave(Emitter<StudySessionState> emit, String id) async {
// Taking back a rating from before would bring the card back.
_undoable.clear();
final at = _round.indexOf(id);
_round.remove(id);
_cardsById.remove(id);
@ -570,6 +646,12 @@ final class const StudySessionEventContinued() extends StudySessionEvent;
/// whether it was right.
final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// Take back the last rating of the round.
final class const StudySessionEventUndone() extends StudySessionEvent;
/// Lock the screen against stray touches, or unlock it.
final class const StudySessionEventLockToggled() extends StudySessionEvent;
/// Show the answer of the card on screen, or hide it again: a tap on the
/// card.
final class const StudySessionEventAnswerToggled() extends StudySessionEvent;
@ -697,6 +779,9 @@ final class const StudySessionStateShowing({
/// Cramming, whether there is a card after this one.
required final bool canGoForward,
/// Whether a rating of the round can be taken back.
final bool canUndo = false,
/// How many chosen tags narrow the session.
required final int tagFilterCount,
@ -711,6 +796,9 @@ final class const StudySessionStateShowing({
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
/// Whether the screen is locked against stray touches.
final bool locked = false,
}) extends StudySessionState {
/// This state with the answer [shown] or hidden.
StudySessionStateShowing withAnswerShown(bool shown) =>
@ -733,11 +821,13 @@ final class const StudySessionStateShowing({
cramUnrated: cramUnrated,
canGoBack: canGoBack,
canGoForward: canGoForward,
canUndo: canUndo,
tagFilterCount: tagFilterCount,
backFirst: backFirst,
answerShown: answerShown ?? this.answerShown,
nextDue: nextDue,
ratingFailed: ratingFailed ?? this.ratingFailed,
locked: locked,
);
@override
@ -754,11 +844,13 @@ final class const StudySessionStateShowing({
cramUnrated,
canGoBack,
canGoForward,
canUndo,
tagFilterCount,
backFirst,
answerShown,
nextDue,
ratingFailed,
locked,
];
}
@ -799,3 +891,16 @@ final class const StudySessionStateRoundDone({
backFirst,
];
}
/// A rating of the round as it can be taken back: the review it left, and
/// the round as it was before it.
final class const _Undoable({
required final StudySessionStateShowing showing,
required final ({String cardId, DateTime at, StudyMode mode}) rating,
required final List<String> round,
required final Map<String, Rating> ratings,
required final Set<String> graduated,
required final Set<String> done,
required final int given,
required final int cramIndex,
});

View file

@ -20,9 +20,9 @@ abstract interface class ReviewGateway() {
/// Appends reviews; reviews are never changed.
Future<void> insert(Iterable<ReviewRow> reviews);
/// Deletes the fresh starts of the cards [cardIds] made at [at], to undo
/// them.
Future<void> deleteResets(Set<String> cardIds, DateTime at);
/// Deletes the entries of [mode] of the cards [cardIds] made at [at], to
/// undo them: a rating, a fresh start.
Future<void> deleteReviews(Set<String> cardIds, DateTime at, String mode);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;

View file

@ -122,9 +122,23 @@ class ReviewRepository(final ReviewGateway _gateway) {
_changes.add(null);
}
/// Takes back the rating of [cardId] at [reviewedAt], studied as [mode].
Future<void> undo({
required String cardId,
required DateTime reviewedAt,
required StudyMode mode,
}) async {
await _gateway.deleteReviews({cardId}, reviewedAt.toUtc(), mode.value);
_changes.add(null);
}
/// Undoes [reset] of [cardIds] at [at].
Future<void> undoReset(Iterable<String> cardIds, DateTime at) async {
await _gateway.deleteResets({...cardIds}, at.toUtc());
await _gateway.deleteReviews(
{...cardIds},
at.toUtc(),
StudyMode.reset.value,
);
_changes.add(null);
}

View file

@ -66,7 +66,11 @@ class SyncReviewGateway(
}
@override
Future<void> deleteResets(Set<String> cardIds, DateTime at) async {
Future<void> deleteReviews(
Set<String> cardIds,
DateTime at,
String mode,
) async {
if (cardIds.isEmpty) {
return;
}
@ -76,7 +80,7 @@ class SyncReviewGateway(
t.cardId.inSet({
for (final id in cardIds) protocol.UuidValue.withValidation(id),
}) &
t.mode.equals('reset') &
t.mode.equals(mode) &
t.reviewedAt.equals(at),
);
_sync.requestSync();

View file

@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Over a locked study session: a light scrim that takes every touch, so a
/// stray one rates nothing and closes nothing. A tap shows the way out for
/// three seconds, darkening the scrim; [onUnlock] is called from there.
///
/// While it is up, a running Pomodoro timer pauses, and it goes on once
/// the lock is gone, as long as nobody touched it meanwhile.
class const StudyLock({required final VoidCallback onUnlock, super.key})
extends StatefulWidget {
@override
State<StudyLock> createState() => _StudyLockState();
}
class _StudyLockState() extends State<StudyLock> {
/// How long the way out shows after a tap.
static const _showFor = Duration(seconds: 3);
late final PomodoroBloc _pomodoro = context.read<PomodoroBloc>();
/// Whether the lock paused the Pomodoro timer, to go on with it after.
var _paused = false;
var _shown = false;
Timer? _hide;
@override
void initState() {
super.initState();
if (_pomodoro.state.run?.isRunning ?? false) {
_paused = true;
_pomodoro.add(const PomodoroEventPauseRequested());
}
}
@override
void dispose() {
_hide?.cancel();
// Gone, also when the session is left locked: the timer goes on if it
// is still paused as the lock left it.
if (_pomodoro.state.run case final run? when _paused && !run.isRunning) {
_pomodoro.add(const PomodoroEventResumeRequested());
}
super.dispose();
}
void _show() {
_hide?.cancel();
setState(() => _shown = true);
_hide = Timer(_showFor, () {
if (mounted) {
setState(() => _shown = false);
}
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
return Material(
type: MaterialType.transparency,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _show,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
color: colors.scrim.withValues(alpha: _shown ? 0.45 : 0.12),
alignment: Alignment.center,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: _shown
? Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.studyUnlock,
iconSize: 32,
icon: const Icon(LucideIcons.lockOpen),
onPressed: widget.onUnlock,
),
const SizedBox(height: 8),
Text(
l10n.studyTapToUnlock,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colors.onSurface,
),
),
],
)
: const SizedBox.shrink(),
),
),
),
);
}
}

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_lock.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/swipe_to_rate.dart';
@ -163,6 +164,14 @@ class const _Header({
),
),
const PomodoroChip(),
if (state case final StudySessionStateShowing showing)
IconButton(
tooltip: l10n.studyUndoLastRating,
icon: const Icon(LucideIcons.undo2),
onPressed: showing.canUndo
? () => _send(context, const StudySessionEventUndone())
: null,
),
IconButton(
tooltip: backFirst
? l10n.studyBackFirstOn
@ -181,6 +190,15 @@ class const _Header({
const StudySessionEventBackFirstToggled(),
),
),
if (state is StudySessionStateShowing)
IconButton(
tooltip: l10n.studyLockScreen,
icon: const Icon(LucideIcons.lock),
onPressed: () => _send(
context,
const StudySessionEventLockToggled(),
),
),
IconButton(
tooltip: l10n.studySettingsTitle,
icon: Badge(
@ -341,86 +359,97 @@ class const _Showing(
};
return Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(
color: theme.colorScheme.surfaceContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
body: Stack(
children: [
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(
color: theme.colorScheme.surfaceContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: state.cram
? CramProgress(
tally: state.cramTally,
unrated: state.cramUnrated,
)
: PillProgress(pills: state.pills),
),
const SizedBox(height: 12),
],
),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: body,
),
),
),
),
if (ratedBelow)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: state.cram
? CramProgress(
tally: state.cramTally,
unrated: state.cramUnrated,
)
: PillProgress(pills: state.pills),
),
const SizedBox(height: 12),
],
),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: body,
),
),
),
),
if (ratedBelow)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
child: state.cram
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(
context,
const StudySessionEventMoved(-1),
)
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 12),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
child: state.cram
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(
context,
const StudySessionEventMoved(-1),
)
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
child: ratings,
),
const SizedBox(width: 12),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
),
child: ratings,
),
),
const SizedBox(width: 12),
IconButton.filledTonal(
tooltip: l10n.studySkipCard,
onPressed: () => _send(
context,
const StudySessionEventMoved(1),
),
icon: const Icon(LucideIcons.chevronRight),
),
],
)
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: ratings,
),
const SizedBox(width: 12),
IconButton.filledTonal(
tooltip: l10n.studySkipCard,
onPressed: () => _send(
context,
const StudySessionEventMoved(1),
),
icon: const Icon(LucideIcons.chevronRight),
),
],
)
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: ratings,
),
),
),
),
],
),
),
if (state.locked)
Positioned.fill(
child: StudyLock(
onUnlock: () =>
_send(context, const StudySessionEventLockToggled()),
),
],
),
),
],
),
);
}

View file

@ -210,6 +210,87 @@ void main() {
});
});
test(
"locked, the session shows it, and the time locked doesn't count",
() async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
now = now.add(const Duration(seconds: 10));
bloc.add(const StudySessionEventLockToggled());
await until(bloc, (s) => s is StudySessionStateShowing && s.locked);
now = now.add(const Duration(minutes: 5));
bloc.add(const StudySessionEventLockToggled());
await until(bloc, (s) => s is StudySessionStateShowing && !s.locked);
now = now.add(const Duration(seconds: 5));
bloc.add(const StudySessionEventRated(Rating.easy));
final done = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(done.duration, const Duration(seconds: 15));
});
},
);
group('Undoing', () {
test('takes back the last rating: its review goes, and the card shows '
'again as it did', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
expect((await showing(bloc, '猫')).canUndo, isFalse);
bloc.add(const StudySessionEventRated(Rating.easy));
final dog = await showing(bloc, '犬');
expect(dog.canUndo, isTrue);
expect(reviewGateway.rows, hasLength(1));
bloc.add(const StudySessionEventUndone());
final neko = await showing(bloc, '猫');
expect(neko.pills.map((pill) => pill.state), [
PillState.active,
PillState.pending,
]);
expect(neko.canUndo, isFalse);
});
expect(reviewGateway.rows, isEmpty);
});
test('goes back one rating at a time', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await cards.create('japanese', card('鳥'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
now = now.add(const Duration(seconds: 1));
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '犬');
now = now.add(const Duration(seconds: 1));
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '鳥');
bloc.add(const StudySessionEventUndone());
expect((await showing(bloc, '犬')).canUndo, isTrue);
bloc.add(const StudySessionEventUndone());
await showing(bloc, '猫');
});
expect(reviewGateway.rows, isEmpty);
});
});
group('When something fails', () {
test("a session that can't be read says so, and starts again on "
'Retry', () async {

View file

@ -164,6 +164,39 @@ void main() {
expect(rated(), Rating.good);
});
testWidgets('locked, the screen takes no taps until unlocked', (
tester,
) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
await study(tester);
await tester.tap(find.byTooltip('Lock screen'));
await tester.pumpAndSettle();
expect(find.text('Tap to unlock'), findsNothing);
// A tap on a rating lands on the lock, which shows the way out for a
// while.
await tester.tap(find.text('Perfect'), warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Tap to unlock'), findsOneWidget);
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(find.text('Tap to unlock'), findsNothing);
await tester.tapAt(const Offset(600, 800));
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.byTooltip('Unlock'));
await tester.pumpAndSettle();
await tester.tap(find.text('Perfect'));
await tester.pumpAndSettle();
expect(find.text('Session complete'), findsOneWidget);
});
testWidgets('leaving in the middle of a round asks first', (tester) async {
await cards.create(
'japanese',

View file

@ -37,13 +37,16 @@ class FakeReviewGateway() implements ReviewGateway {
}
@override
Future<void> deleteResets(Set<String> cardIds, DateTime at) async =>
rows.removeWhere(
(row) =>
cardIds.contains(row.cardId) &&
row.mode == 'reset' &&
row.reviewedAt == at,
);
Future<void> deleteReviews(
Set<String> cardIds,
DateTime at,
String mode,
) async => rows.removeWhere(
(row) =>
cardIds.contains(row.cardId) &&
row.mode == mode &&
row.reviewedAt == at,
);
@override
Stream<void> get remoteChanges => const Stream.empty();