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:
parent
9f1cfc1cdd
commit
aad6110da3
14 changed files with 515 additions and 87 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue