Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions lib/features/routines/models/workout_progress.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/

/// 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<int> 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<String, dynamic> 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<dynamic>).cast<int>(),
);

Map<String, dynamic> 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'
')';
}
7 changes: 7 additions & 0 deletions lib/features/routines/providers/gym_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions lib/features/routines/providers/gym_state_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import 'dart:convert';

import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
Expand All @@ -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';

Expand Down Expand Up @@ -131,6 +134,115 @@ class GymStateNotifier extends _$GymStateNotifier {
);
}

/// Absolute page indices of the log pages already marked as done
List<int> 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<void> _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<void> _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<int?> 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<String, dynamic>);
} 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<PageEntry> _pagesWithLogsDone(List<int> 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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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() {
Expand All @@ -484,5 +599,6 @@ class GymStateNotifier extends _$GymStateNotifier {
validUntil: clock.now().add(DEFAULT_DURATION),
workoutStart: clock.now(),
);
_clearProgress();
}
}
36 changes: 31 additions & 5 deletions lib/features/routines/screens/gym_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,15 +36,39 @@ class GymModeScreen extends ConsumerWidget {

static const routeName = '/gym-mode';

/// Leaves gym mode, but only if the user confirms it
Future<void> _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)),
),
),
);
}
Expand Down
6 changes: 5 additions & 1 deletion lib/features/routines/widgets/gym_mode/gym_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ class _GymModeState extends ConsumerState<GymMode> {
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<Widget> _getContent(GymModeState state) {
Expand Down
52 changes: 52 additions & 0 deletions lib/features/routines/widgets/gym_mode/leave_workout_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/

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<bool> confirmLeaveWorkout(BuildContext context) async {
final confirmed = await showDialog<bool>(
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;
}
4 changes: 3 additions & 1 deletion lib/features/routines/widgets/gym_mode/navigation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading