From a7410266a80bfc927d86aad6e3ad7332413949d2 Mon Sep 17 00:00:00 2001 From: andratwiro Date: Sun, 26 Jul 2026 16:35:50 +0200 Subject: [PATCH 1/2] Ask for confirmation before leaving a running workout Gym mode could be left with a single, easily accidental gesture. The iOS back swipe and the Android back gesture pop the route directly, and the close button called pop() unconditionally. Leaving mid-workout drops the session, the elapsed timer and the progress through the pages (logs that were already saved are kept). A PopScope now blocks the pop while a workout is in progress, that is from the moment the start page is left until the summary page is reached, and asks for confirmation instead. The close button uses maybePop() so that it goes through the same guard. Dismissing the dialog without answering keeps the workout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQwZcRK8yTmBjCAppoeqXW --- .../routines/providers/gym_state.dart | 6 + lib/features/routines/screens/gym_mode.dart | 36 +++++- .../gym_mode/leave_workout_dialog.dart | 52 +++++++++ .../routines/widgets/gym_mode/navigation.dart | 4 +- lib/l10n/app_en.arb | 12 ++ .../routines/screens/gym_mode_test.dart | 110 ++++++++++++++++++ 6 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 lib/features/routines/widgets/gym_mode/leave_workout_dialog.dart diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index ffb5f9b27..889abfa87 100644 --- a/lib/features/routines/providers/gym_state.dart +++ b/lib/features/routines/providers/gym_state.dart @@ -326,6 +326,12 @@ class GymModeState { return null; } + /// Whether the user is currently inside a running workout + /// + /// True from the moment the start page is left until the summary page is + /// reached, i.e. exactly while there is progress that leaving would discard. + bool get isWorkoutInProgress => isInitialized && currentPage > 0 && currentPage < totalPages - 1; + double get ratioCompleted { if (totalPages == 0) { return 0.0; diff --git a/lib/features/routines/screens/gym_mode.dart b/lib/features/routines/screens/gym_mode.dart index 057c3fc72..664194ed4 100644 --- a/lib/features/routines/screens/gym_mode.dart +++ b/lib/features/routines/screens/gym_mode.dart @@ -19,7 +19,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/features/routines/providers/gym_state_notifier.dart'; import 'package:wger/features/routines/widgets/gym_mode/gym_mode.dart'; +import 'package:wger/features/routines/widgets/gym_mode/leave_workout_dialog.dart'; class GymModeArguments { final int routineId; @@ -34,15 +36,39 @@ class GymModeScreen extends ConsumerWidget { static const routeName = '/gym-mode'; + /// Leaves gym mode, but only if the user confirms it + Future _confirmAndLeave(BuildContext context) async { + if (await confirmLeaveWorkout(context) && context.mounted) { + Navigator.of(context).pop(); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final args = ModalRoute.of(context)!.settings.arguments as GymModeArguments; + final workoutInProgress = ref.watch( + gymStateProvider.select((state) => state.isWorkoutInProgress), + ); - return Scaffold( - // backgroundColor: Theme.of(context).cardColor, - // primary: false, - body: SafeArea( - child: WidescreenWrapper(child: GymMode(args)), + return PopScope( + // While a workout is running, leaving is always an explicit choice: the + // iOS back swipe and the Android back gesture are easy to trigger by + // accident (e.g. when reaching for a podcast app mid-set), and popping + // the route drops the session, the elapsed timer and the progress + // through the workout. + canPop: !workoutInProgress, + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + return; + } + _confirmAndLeave(context); + }, + child: Scaffold( + // backgroundColor: Theme.of(context).cardColor, + // primary: false, + body: SafeArea( + child: WidescreenWrapper(child: GymMode(args)), + ), ), ); } diff --git a/lib/features/routines/widgets/gym_mode/leave_workout_dialog.dart b/lib/features/routines/widgets/gym_mode/leave_workout_dialog.dart new file mode 100644 index 000000000..cee00d685 --- /dev/null +++ b/lib/features/routines/widgets/gym_mode/leave_workout_dialog.dart @@ -0,0 +1,52 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +/// Asks the user to confirm leaving gym mode with a workout in progress. +/// +/// Returns whether the workout should be left. Dismissing the dialog without +/// choosing (tapping outside, back gesture) keeps the workout, so that a second +/// accidental gesture can't leave it either. +Future confirmLeaveWorkout(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + final i18n = AppLocalizations.of(dialogContext); + return AlertDialog( + title: Text(i18n.leaveWorkoutTitle), + content: Text(i18n.leaveWorkoutConfirmation), + actions: [ + TextButton( + key: const ValueKey('keep-training-button'), + child: Text(MaterialLocalizations.of(dialogContext).cancelButtonLabel), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + TextButton( + key: const ValueKey('leave-workout-button'), + child: Text(i18n.leaveWorkout), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ); + }, + ); + + return confirmed ?? false; +} diff --git a/lib/features/routines/widgets/gym_mode/navigation.dart b/lib/features/routines/widgets/gym_mode/navigation.dart index 9025edd86..5e979b51f 100644 --- a/lib/features/routines/widgets/gym_mode/navigation.dart +++ b/lib/features/routines/widgets/gym_mode/navigation.dart @@ -42,8 +42,10 @@ class NavigationHeader extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.close), + // maybePop instead of pop so that the PopScope guarding gym mode + // gets to ask for confirmation while a workout is in progress onPressed: () { - Navigator.of(context).pop(); + Navigator.of(context).maybePop(); }, ), Expanded( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c8026edc9..dd645e951 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1290,6 +1290,18 @@ "@endWorkout": { "description": "Use the imperative, label on button to finish the current workout in gym mode" }, + "leaveWorkoutTitle": "Leave the workout?", + "@leaveWorkoutTitle": { + "description": "Title of the dialog asking to confirm leaving gym mode while a workout is in progress" + }, + "leaveWorkoutConfirmation": "The workout is still in progress. Sets you already saved are kept.", + "@leaveWorkoutConfirmation": { + "description": "Body of the dialog asking to confirm leaving gym mode while a workout is in progress" + }, + "leaveWorkout": "Leave", + "@leaveWorkout": { + "description": "Use the imperative, label on the button that confirms leaving gym mode while a workout is in progress" + }, "themeMode": "Theme mode", "darkMode": "Always dark mode", "lightMode": "Always light mode", diff --git a/test/features/routines/screens/gym_mode_test.dart b/test/features/routines/screens/gym_mode_test.dart index 4835b65af..c8eaf40c4 100644 --- a/test/features/routines/screens/gym_mode_test.dart +++ b/test/features/routines/screens/gym_mode_test.dart @@ -492,4 +492,114 @@ void main() { }, semanticsEnabled: false, ); + + /// Opens gym mode and advances past the start page, so that a workout counts + /// as in progress + Future enterRunningWorkout(WidgetTester tester) async { + await tester.pumpWidget(renderGymMode()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StartPage), findsOneWidget); + + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + expect(find.byType(ExerciseOverview), findsOneWidget); + } + + testWidgets( + 'the close button leaves the start page without asking', + (WidgetTester tester) async { + // Nothing has happened yet on the start page, so there is nothing to + // protect and closing stays a single tap. + await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async { + await tester.pumpWidget(renderGymMode()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StartPage), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.text('Leave the workout?'), findsNothing); + expect(find.byType(GymModeScreen), findsNothing); + }); + }, + semanticsEnabled: false, + ); + + testWidgets( + 'the close button asks for confirmation during a workout', + (WidgetTester tester) async { + await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async { + await enterRunningWorkout(tester); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.text('Leave the workout?'), findsOneWidget); + + // Cancelling keeps the user on the very page they were on + await tester.tap(find.byKey(const ValueKey('keep-training-button'))); + await tester.pumpAndSettle(); + expect(find.text('Leave the workout?'), findsNothing); + expect(find.byType(ExerciseOverview), findsOneWidget); + + // Confirming leaves gym mode + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('leave-workout-button'))); + await tester.pumpAndSettle(); + expect(find.byType(GymModeScreen), findsNothing); + }); + }, + semanticsEnabled: false, + ); + + testWidgets( + 'the system back gesture asks for confirmation during a workout', + (WidgetTester tester) async { + // This is the accident the guard is really about: the iOS back swipe and + // the Android back gesture both arrive here as a pop request. + await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async { + await enterRunningWorkout(tester); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('Leave the workout?'), findsOneWidget); + expect(find.byType(ExerciseOverview), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('keep-training-button'))); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseOverview), findsOneWidget); + }); + }, + semanticsEnabled: false, + ); + + testWidgets( + 'dismissing the confirmation keeps the workout', + (WidgetTester tester) async { + // A dialog dismissed without an answer (tap outside) must not be read as + // a confirmation, otherwise a stray tap leaves the workout after all. + await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async { + await enterRunningWorkout(tester); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.text('Leave the workout?'), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + expect(find.text('Leave the workout?'), findsNothing); + expect(find.byType(ExerciseOverview), findsOneWidget); + }); + }, + semanticsEnabled: false, + ); } From 7a1f74956915ff6842aec9ae7faa7ffcdbd9e6c6 Mon Sep 17 00:00:00 2001 From: andratwiro Date: Sun, 26 Jul 2026 16:42:59 +0200 Subject: [PATCH 2/2] Continue a workout the OS interrupted The gym mode state only lived in memory. Riverpod keeps it for as long as the app runs, so leaving gym mode and coming back already continued the workout, but a backgrounded app that the OS kills (e.g. after switching to a music app between two sets) lost the session, the elapsed time and which sets had been done. The progress is now also written to the preferences and read back when gym mode is opened. Only what can't be recalculated is stored, the pages themselves still come from the routine, which is why the finished log pages are referenced by page index instead of by their UUID (those are generated anew on every calculation). The stored progress is only used for the same day and iteration, only while the workout is plausibly still running (the existing 5 hour window), and only if the routine still has the page it stopped on. A workout that is still in memory wins over the stored one, and finishing a workout clears it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQwZcRK8yTmBjCAppoeqXW --- .../routines/models/workout_progress.dart | 72 ++++++++++ .../routines/providers/gym_state.dart | 1 + .../providers/gym_state_notifier.dart | 116 ++++++++++++++++ .../routines/widgets/gym_mode/gym_mode.dart | 6 +- .../routines/providers/gym_state_test.dart | 128 ++++++++++++++++++ .../routines/screens/gym_mode_test.dart | 23 ++++ 6 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 lib/features/routines/models/workout_progress.dart diff --git a/lib/features/routines/models/workout_progress.dart b/lib/features/routines/models/workout_progress.dart new file mode 100644 index 000000000..63c1a5611 --- /dev/null +++ b/lib/features/routines/models/workout_progress.dart @@ -0,0 +1,72 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/// The progress through a running workout, as stored in the preferences +/// +/// Only the parts that can't be recalculated are kept. The pages themselves are +/// derived from the routine again on the next start, which is also why the +/// finished log pages are referenced by their page index: the UUIDs are +/// generated anew every time the pages are calculated. +class WorkoutProgress { + final int dayId; + final int iteration; + final int currentPage; + final DateTime workoutStart; + final DateTime validUntil; + + /// Absolute page indices of the log pages already marked as done + final List donePageIndices; + + const WorkoutProgress({ + required this.dayId, + required this.iteration, + required this.currentPage, + required this.workoutStart, + required this.validUntil, + required this.donePageIndices, + }); + + factory WorkoutProgress.fromJson(Map json) => WorkoutProgress( + dayId: json['dayId'] as int, + iteration: json['iteration'] as int, + currentPage: json['currentPage'] as int, + workoutStart: DateTime.parse(json['workoutStart'] as String), + validUntil: DateTime.parse(json['validUntil'] as String), + donePageIndices: (json['donePageIndices'] as List).cast(), + ); + + Map toJson() => { + 'dayId': dayId, + 'iteration': iteration, + 'currentPage': currentPage, + 'workoutStart': workoutStart.toIso8601String(), + 'validUntil': validUntil.toIso8601String(), + 'donePageIndices': donePageIndices, + }; + + @override + String toString() => + 'WorkoutProgress(' + 'dayId: $dayId, ' + 'iteration: $iteration, ' + 'currentPage: $currentPage, ' + 'workoutStart: $workoutStart, ' + 'validUntil: $validUntil, ' + 'donePageIndices: $donePageIndices' + ')'; +} diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index 889abfa87..cf64459c5 100644 --- a/lib/features/routines/providers/gym_state.dart +++ b/lib/features/routines/providers/gym_state.dart @@ -34,6 +34,7 @@ const PREFS_COUNTDOWN_DURATION = 'countdownDurationSecondsPrefs'; const PREFS_LOG_SCOPE_WEEKS = 'logScopeWeeksPrefs'; const PREFS_SHOW_DISTINCT_LOGS = 'showDistinctLogsPrefs'; const PREFS_SHOW_WORKOUT_DURATION = 'showWorkoutDurationPrefs'; +const PREFS_WORKOUT_PROGRESS = 'workoutProgressPrefs'; /// In seconds const DEFAULT_COUNTDOWN_DURATION = 180; diff --git a/lib/features/routines/providers/gym_state_notifier.dart b/lib/features/routines/providers/gym_state_notifier.dart index 5dc514e00..8ff828052 100644 --- a/lib/features/routines/providers/gym_state_notifier.dart +++ b/lib/features/routines/providers/gym_state_notifier.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; @@ -25,6 +27,7 @@ import 'package:wger/features/exercises/models/exercise.dart'; import 'package:wger/features/routines/models/log.dart'; import 'package:wger/features/routines/models/routine.dart'; import 'package:wger/features/routines/models/set_config_data.dart'; +import 'package:wger/features/routines/models/workout_progress.dart'; import 'package:wger/features/routines/providers/gym_log_notifier.dart'; import 'package:wger/features/routines/providers/gym_state.dart'; @@ -131,6 +134,115 @@ class GymStateNotifier extends _$GymStateNotifier { ); } + /// Absolute page indices of the log pages already marked as done + List get _donePageIndices => state.pages + .expand((page) => page.slotPages) + .where((slotPage) => slotPage.logDone) + .map((slotPage) => slotPage.pageIndex) + .toList(); + + /// Stores the progress through the running workout + /// + /// Riverpod keeps the state alive for as long as the app is running, but the + /// OS can kill a backgrounded app at any point, e.g. while the user switches + /// to a music app between two sets. Without this, that loses the session, the + /// elapsed time and which sets were already done. + Future _saveProgress() async { + // Before initData there is no dayId to store the progress under + if (!state.isInitialized) { + return; + } + + final progress = WorkoutProgress( + dayId: state.dayId, + iteration: state.iteration, + currentPage: state.currentPage, + workoutStart: state.workoutStart, + validUntil: state.validUntil, + donePageIndices: _donePageIndices, + ); + + await PreferenceHelper.asyncPref.setString( + PREFS_WORKOUT_PROGRESS, + json.encode(progress.toJson()), + ); + _logger.finer('Saved $progress'); + } + + Future _clearProgress() async { + await PreferenceHelper.asyncPref.remove(PREFS_WORKOUT_PROGRESS); + _logger.finer('Cleared stored workout progress'); + } + + /// Continues a workout that was interrupted by the app being killed + /// + /// Returns the page to continue on, or null when there is nothing to restore. + /// Call this after [initData] and [calculatePages], the restored "done" flags + /// are matched against the pages as they were just calculated. + Future restoreProgress() async { + final stored = await PreferenceHelper.asyncPref.getString(PREFS_WORKOUT_PROGRESS); + if (stored == null) { + return null; + } + + // A workout that is still in memory (the app was not killed, the user only + // left gym mode and came back) is more current than what was stored. + if (state.currentPage != 0) { + return null; + } + + final WorkoutProgress progress; + try { + progress = WorkoutProgress.fromJson(json.decode(stored) as Map); + } on Exception catch (e) { + _logger.warning('Discarding unreadable stored workout progress: $e'); + await _clearProgress(); + return null; + } + + // Only ever continue the same workout, only while it is plausibly still + // running, and only if the routine still has the page it stopped on (it + // might have been edited in the meantime). + if (progress.dayId != state.dayId || + progress.iteration != state.iteration || + progress.validUntil.isBefore(clock.now()) || + progress.currentPage >= state.totalPages) { + _logger.fine('Stored workout progress does not apply any more, discarding it'); + await _clearProgress(); + return null; + } + + state = state.copyWith( + currentPage: progress.currentPage, + workoutStart: progress.workoutStart, + validUntil: progress.validUntil, + pages: _pagesWithLogsDone(progress.donePageIndices), + ); + + _logger.fine('Restored workout progress on page ${progress.currentPage}'); + + return progress.currentPage; + } + + /// Copy of the current pages with the log pages in [donePageIndices] done + List _pagesWithLogsDone(List donePageIndices) { + return state.pages.map((page) { + if (page.type != PageType.set) { + return page; + } + + return page.copyWith( + slotPages: page.slotPages.map((slotPage) { + if (slotPage.type != SlotPageType.log) { + return slotPage; + } + + return slotPage.copyWith(logDone: donePageIndices.contains(slotPage.pageIndex)); + }).toList(), + ); + }).toList(); + } + /// Calculates the page entries void calculatePages() { var pageIndex = 0; @@ -303,6 +415,7 @@ class GymStateNotifier extends _$GymStateNotifier { void setCurrentPage(int page) { state = state.copyWith(currentPage: page); + _saveProgress(); // Ensure that there is a log entry for the current slot entry final slotEntryPage = state.getSlotEntryPageByIndex(); @@ -386,6 +499,7 @@ class GymStateNotifier extends _$GymStateNotifier { }).toList(); state = state.copyWith(pages: updatedPages); + _saveProgress(); _logger.fine('Set logDone=$isDone for slot page UUID $uuid'); } @@ -472,6 +586,7 @@ class GymStateNotifier extends _$GymStateNotifier { void startWorkout() { _logger.fine('Setting workout start time'); state = state.copyWith(workoutStart: clock.now()); + _saveProgress(); } void clear() { @@ -484,5 +599,6 @@ class GymStateNotifier extends _$GymStateNotifier { validUntil: clock.now().add(DEFAULT_DURATION), workoutStart: clock.now(), ); + _clearProgress(); } } diff --git a/lib/features/routines/widgets/gym_mode/gym_mode.dart b/lib/features/routines/widgets/gym_mode/gym_mode.dart index 7888972bb..42f6e97da 100644 --- a/lib/features/routines/widgets/gym_mode/gym_mode.dart +++ b/lib/features/routines/widgets/gym_mode/gym_mode.dart @@ -103,7 +103,11 @@ class _GymModeState extends ConsumerState { await gymViewModel.loadPrefs(); gymViewModel.calculatePages(); - return initialPage; + // A workout the OS interrupted (app killed while in the background) + // continues where it stopped, the pages have to be calculated by now + final restoredPage = await gymViewModel.restoreProgress(); + + return restoredPage ?? initialPage; } List _getContent(GymModeState state) { diff --git a/test/features/routines/providers/gym_state_test.dart b/test/features/routines/providers/gym_state_test.dart index ea27b573d..4251e9009 100644 --- a/test/features/routines/providers/gym_state_test.dart +++ b/test/features/routines/providers/gym_state_test.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -384,6 +386,132 @@ void main() { }); }); + group('GymStateNotifier workout progress', () { + /// Simulates the app being killed and gym mode being opened again: a new + /// container means a new (empty) notifier, while the stored preferences + /// survive, exactly as on a real restart. + Future restartedNotifier({int dayId = 1, int iteration = 1}) async { + final restartedContainer = ProviderContainer.test(); + final restarted = restartedContainer.read(gymStateProvider.notifier); + restarted.initData(getTestRoutine(), dayId, iteration); + restarted.calculatePages(); + + return restarted; + } + + /// Runs a workout up to [currentPage], with one log page marked as done + Future runWorkout({int currentPage = 2}) async { + notifier.initData(getTestRoutine(), 1, 1); + notifier.calculatePages(); + notifier.markSlotPageAsDone(notifier.state.pages[1].slotPages[1].uuid, isDone: true); + notifier.setCurrentPage(currentPage); + await pumpEventQueue(); + } + + test('Continues an interrupted workout where it stopped', () async { + await runWorkout(); + + final restarted = await restartedNotifier(); + final restoredPage = await restarted.restoreProgress(); + + expect(restoredPage, 2); + expect(restarted.state.currentPage, 2); + expect( + restarted.state.pages[1].slotPages[1].logDone, + isTrue, + reason: 'the set that was already logged is still marked as done', + ); + }); + + test('Keeps the elapsed time of an interrupted workout', () async { + await withClock(Clock.fixed(DateTime(2026, 7, 26, 18, 30)), () async { + await runWorkout(); + }); + + // Two minutes later the app is started again + await withClock(Clock.fixed(DateTime(2026, 7, 26, 18, 32)), () async { + final restarted = await restartedNotifier(); + await restarted.restoreProgress(); + + expect(restarted.state.workoutStart, DateTime(2026, 7, 26, 18, 30)); + }); + }); + + test('Does not continue the workout of another day', () async { + await runWorkout(); + + // Rewrite the stored progress to belong to a different day of the + // routine, the workout that is opened now is not the one that stopped + final stored = + json.decode((await PreferenceHelper.asyncPref.getString(PREFS_WORKOUT_PROGRESS))!) + as Map; + stored['dayId'] = 2; + await PreferenceHelper.asyncPref.setString(PREFS_WORKOUT_PROGRESS, json.encode(stored)); + + final restarted = await restartedNotifier(); + + expect(await restarted.restoreProgress(), isNull); + expect(restarted.state.currentPage, 0); + }); + + test('Does not continue a workout that is too old', () async { + await withClock(Clock.fixed(DateTime(2026, 7, 26, 18, 30)), () async { + await runWorkout(); + }); + + // The next morning the workout is long over + await withClock(Clock.fixed(DateTime(2026, 7, 27, 9, 0)), () async { + final restarted = await restartedNotifier(); + + expect(await restarted.restoreProgress(), isNull); + expect(restarted.state.currentPage, 0); + }); + }); + + test('Does not continue on a page the routine no longer has', () async { + await runWorkout(); + + // The routine was edited in the meantime and is now shorter than the + // page the workout stopped on + final restarted = await restartedNotifier(); + restarted.state = restarted.state.copyWith(pages: restarted.state.pages.sublist(0, 1)); + + expect(await restarted.restoreProgress(), isNull); + }); + + test('Keeps the workout in memory over the stored one', () async { + await runWorkout(); + + // Same notifier, i.e. the app kept running and the user only left gym + // mode and came back. Nothing to restore, the state is already there. + expect(await notifier.restoreProgress(), isNull); + expect(notifier.state.currentPage, 2); + }); + + test('Forgets the workout once it is finished', () async { + await runWorkout(); + + notifier.clear(); + await pumpEventQueue(); + + final restarted = await restartedNotifier(); + expect(await restarted.restoreProgress(), isNull); + }); + + test('Ignores stored progress that can not be read', () async { + await PreferenceHelper.asyncPref.setString(PREFS_WORKOUT_PROGRESS, 'not json'); + + final restarted = await restartedNotifier(); + + expect(await restarted.restoreProgress(), isNull); + expect( + await PreferenceHelper.asyncPref.getString(PREFS_WORKOUT_PROGRESS), + isNull, + reason: 'the unreadable value is cleared instead of failing again later', + ); + }); + }); + group('GymModeState.copyWith', () { test('Keeps the log scope when it is not passed', () { final state = notifier.state.copyWith(logScopeWeeks: 8); diff --git a/test/features/routines/screens/gym_mode_test.dart b/test/features/routines/screens/gym_mode_test.dart index c8eaf40c4..006bf6dde 100644 --- a/test/features/routines/screens/gym_mode_test.dart +++ b/test/features/routines/screens/gym_mode_test.dart @@ -493,9 +493,30 @@ void main() { semanticsEnabled: false, ); + /// Puts the preferences gym mode reads on open into a known state + /// + /// Installing a fresh in-memory store in setUp does not isolate the tests in + /// this file from one another, what one test writes is still visible in the + /// next. Both of these decide what is on screen after opening gym mode: a + /// workout left in the middle is continued where it stopped, and the exercise + /// pages setting decides which page follows the start page. + /// + /// This runs through [runAsync] because the notifier does not await its + /// writes, and a pending one uses real timers, which the fake clock of the + /// test binding never fires. + Future resetGymModePrefs(WidgetTester tester) async { + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + await PreferenceHelper.asyncPref.remove(PREFS_WORKOUT_PROGRESS); + await PreferenceHelper.asyncPref.setBool(PREFS_SHOW_EXERCISES, true); + }); + } + /// Opens gym mode and advances past the start page, so that a workout counts /// as in progress Future enterRunningWorkout(WidgetTester tester) async { + await resetGymModePrefs(tester); + await tester.pumpWidget(renderGymMode()); await tester.pumpAndSettle(); await tester.tap(find.byType(TextButton)); @@ -515,6 +536,8 @@ void main() { // Nothing has happened yet on the start page, so there is nothing to // protect and closing stays a single tap. await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async { + await resetGymModePrefs(tester); + await tester.pumpWidget(renderGymMode()); await tester.pumpAndSettle(); await tester.tap(find.byType(TextButton));