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 ffb5f9b27..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; @@ -326,6 +327,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/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/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/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/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/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 4835b65af..006bf6dde 100644 --- a/test/features/routines/screens/gym_mode_test.dart +++ b/test/features/routines/screens/gym_mode_test.dart @@ -492,4 +492,137 @@ 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)); + 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 resetGymModePrefs(tester); + + 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, + ); }