Allow the user to control the gym mode

This commit is contained in:
Roland Geider
2025-11-11 14:09:56 +01:00
parent ae6db6ee07
commit 694615596f
4 changed files with 212 additions and 119 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
part 'gym_state.g.dart';
@@ -11,27 +12,34 @@ const DEFAULT_DURATION = Duration(hours: 5);
class GymState {
final Map<Exercise, int> exercisePages;
final bool showExercisePages;
final bool showTimerPages;
final int currentPage;
final int totalPages;
final int totalElements;
final int? dayId;
late TimeOfDay startTime;
late DateTime validUntil;
final TimeOfDay startTime;
final DateTime validUntil;
GymState({
this.exercisePages = const {},
this.showExercisePages = true,
this.showTimerPages = true,
this.currentPage = 0,
this.totalPages = 1,
this.totalElements = 1,
this.dayId,
DateTime? validUntil,
TimeOfDay? startTime,
}) {
this.validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION);
this.startTime = startTime ?? TimeOfDay.fromDateTime(clock.now());
}
}) : validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION),
startTime = startTime ?? TimeOfDay.fromDateTime(clock.now());
GymState copyWith({
Map<Exercise, int>? exercisePages,
bool? showExercisePages,
bool? showTimerPages,
int? currentPage,
int? totalPages,
int? totalElements,
int? dayId,
DateTime? validUntil,
TimeOfDay? startTime,
@@ -39,7 +47,10 @@ class GymState {
return GymState(
exercisePages: exercisePages ?? this.exercisePages,
showExercisePages: showExercisePages ?? this.showExercisePages,
showTimerPages: showTimerPages ?? this.showTimerPages,
currentPage: currentPage ?? this.currentPage,
totalPages: totalPages ?? this.totalPages,
totalElements: totalElements ?? this.totalElements,
dayId: dayId ?? this.dayId,
validUntil: validUntil ?? this.validUntil,
startTime: startTime ?? this.startTime,
@@ -50,51 +61,123 @@ class GymState {
String toString() {
return 'GymState('
'currentPage: $currentPage, '
'showExercisePages: $showExercisePages, '
'totalPages: $totalPages, '
'totalElements: $totalElements, '
'exercisePages: ${exercisePages.length} exercises, '
'dayId: $dayId, '
'validUntil: $validUntil '
'startTime: $startTime, '
'showExercisePages: $showExercisePages, '
'showTimerPages: $showTimerPages, '
')';
}
}
@riverpod
@Riverpod(keepAlive: true)
class GymStateNotifier extends _$GymStateNotifier {
final _logger = Logger('GymStateNotifier');
@override
GymState build() => GymState();
GymState build() {
_logger.finer('Initializing GymStateNotifier with default state');
return GymState();
}
void computePagesForDay(DayData dayData, Exercise Function(int) findExerciseById) {
var totalElements = 1;
var totalPages = 1;
final Map<Exercise, int> exercisePages = {};
for (final slot in dayData.slots) {
totalElements += slot.setConfigs.length;
// exercise overview page
if (state.showExercisePages) {
totalPages += 1;
}
for (final config in slot.setConfigs) {
// log + timer per set
if (state.showTimerPages) {
totalPages += (config.nrOfSets! * 2).toInt();
} else {
totalPages += config.nrOfSets!.toInt();
}
}
}
var currentPage = 1;
for (final slot in dayData.slots) {
var firstPage = true;
for (final config in slot.setConfigs) {
final exercise = findExerciseById(config.exerciseId);
if (firstPage) {
exercisePages[exercise] = currentPage;
currentPage++;
}
currentPage += 2;
firstPage = false;
}
}
_logger.finer('Total pages: $totalPages');
_logger.finer('Total elements: $totalElements');
state = state.copyWith(
exercisePages: exercisePages,
totalPages: totalPages,
totalElements: totalElements,
);
}
Future<int> initForDay(
DayData dayData, {
required Exercise Function(int) findExerciseById,
}) async {
final newDayId = dayData.day!.id!;
final validUntil = state.validUntil;
final currentPage = state.currentPage;
final savedDayId = state.dayId;
final shouldReset = newDayId != savedDayId || validUntil.isBefore(DateTime.now());
if (shouldReset) {
_logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.');
}
final initialPage = shouldReset ? 0 : currentPage;
// compute pages (pure, uses callback for exercise lookup)
computePagesForDay(dayData, findExerciseById);
// set dayId and initial page
state = state.copyWith(
dayId: newDayId,
currentPage: initialPage,
);
return initialPage;
}
void setCurrentPage(int page) {
// _logger.fine('Setting page from ${state.currentPage} to $page');
state = state.copyWith(currentPage: page);
}
void toggleExercisePages() {
state = state.copyWith(showExercisePages: !state.showExercisePages);
void setShowExercisePages(bool value) {
state = state.copyWith(showExercisePages: value);
}
void setShowTimerPages(bool value) {
state = state.copyWith(showTimerPages: value);
}
void setDayId(int dayId) {
// _logger.fine('Setting day id from ${state.dayId} to $dayId');
state = state.copyWith(dayId: dayId);
}
void setExercisePages(Map<Exercise, int> exercisePages) {
// _logger.fine('Setting exercise pages - ${exercisePages.length} exercises');
state = state.copyWith(exercisePages: exercisePages);
// _logger.fine(
// 'Exercise pages set - ${exercisePages.entries.map((e) => '${e.key.id}: ${e.value}').join(', ')}');
}
void clear() {
_logger.fine('Clearing state');
state = state.copyWith(
exercisePages: {},
currentPage: 0,
dayId: null,
validUntil: DateTime.now().add(DEFAULT_DURATION),
startTime: TimeOfDay.now(),
);
state = GymState();
}
}

View File

@@ -19,7 +19,7 @@ final class GymStateNotifierProvider extends $NotifierProvider<GymStateNotifier,
argument: null,
retry: null,
name: r'gymStateProvider',
isAutoDispose: true,
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@@ -40,7 +40,7 @@ final class GymStateNotifierProvider extends $NotifierProvider<GymStateNotifier,
}
}
String _$gymStateNotifierHash() => r'bb6bdc27f41e052312a159dd9bfca0873c474c59';
String _$gymStateNotifierHash() => r'ee943c23a68e678830c65a0c53bfd609feb6bf62';
abstract class _$GymStateNotifier extends $Notifier<GymState> {
GymState build();

View File

@@ -21,7 +21,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart' as provider;
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/providers/exercises.dart';
import 'package:wger/providers/gym_state.dart';
@@ -45,101 +44,53 @@ class GymMode extends ConsumerStatefulWidget {
}
class _GymModeState extends ConsumerState<GymMode> {
var _totalElements = 1;
var _totalPages = 1;
late Future<int> _initData;
bool _initialPageJumped = false;
/// Map with the first (navigation) page for each exercise
final Map<Exercise, int> _exercisePages = {};
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: 0);
_initData = _loadGymState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_initData = _loadGymState();
_controller = PageController(initialPage: 0);
_calculatePages();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(gymStateProvider.notifier).setExercisePages(_exercisePages);
});
}
Future<int> _loadGymState() async {
// Re-fetch the current routine data to ensure we have the latest session
// data since it is possible that the user created or deleted it from the
// web interface.
await context.read<RoutinesProvider>().fetchAndSetRoutineFull(
await provider.Provider.of<RoutinesProvider>(context, listen: false).fetchAndSetRoutineFull(
widget._dayDataGym.day!.routineId,
);
widget._logger.fine('Refreshed routine data');
final validUntil = ref.read(gymStateProvider).validUntil;
final currentPage = ref.read(gymStateProvider).currentPage;
final savedDayId = ref.read(gymStateProvider).dayId;
final newDayId = widget._dayDataGym.day!.id!;
final shouldReset = newDayId != savedDayId || validUntil.isBefore(DateTime.now());
if (shouldReset) {
widget._logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.');
}
final initialPage = shouldReset ? 0 : currentPage;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(gymStateProvider.notifier)
..setDayId(newDayId)
..setCurrentPage(initialPage);
});
final gymProvider = ref.read(gymStateProvider.notifier);
final initialPage = await gymProvider.initForDay(
widget._dayDataGym,
findExerciseById: (id) =>
provider.Provider.of<ExercisesProvider>(context, listen: false).findExerciseById(id),
);
return initialPage;
}
void _calculatePages() {
for (final slot in widget._dayDataGym.slots) {
_totalElements += slot.setConfigs.length;
// add 1 for each exercise
_totalPages += 1;
for (final config in slot.setConfigs) {
// add nrOfSets * 2, 1 for log page and 1 for timer
_totalPages += (config.nrOfSets! * 2).toInt();
}
}
_exercisePages.clear();
var currentPage = 1;
for (final slot in widget._dayDataGym.slots) {
var firstPage = true;
for (final config in slot.setConfigs) {
final exercise = context.read<ExercisesProvider>().findExerciseById(config.exerciseId);
if (firstPage) {
_exercisePages[exercise] = currentPage;
currentPage++;
}
currentPage += 2;
firstPage = false;
}
}
}
List<Widget> getContent() {
final state = ref.watch(gymStateProvider);
final exerciseProvider = context.read<ExercisesProvider>();
final routinesProvider = context.read<RoutinesProvider>();
List<Widget> _getContent(GymState state) {
final exerciseProvider = provider.Provider.of<ExercisesProvider>(context, listen: false);
final routinesProvider = provider.Provider.of<RoutinesProvider>(context, listen: false);
var currentElement = 1;
final List<Widget> out = [];
final totalElements = state.totalElements;
final totalPages = state.totalPages;
final exercisePages = state.exercisePages;
for (final slotData in widget._dayDataGym.slots) {
var firstPage = true;
for (final config in slotData.setConfigs) {
final ratioCompleted = currentElement / _totalElements;
final ratioCompleted = currentElement / totalElements;
final exercise = exerciseProvider.findExerciseById(config.exerciseId);
currentElement++;
@@ -149,8 +100,8 @@ class _GymModeState extends ConsumerState<GymMode> {
_controller,
exercise,
ratioCompleted,
state.exercisePages,
_totalPages,
exercisePages,
totalPages,
),
);
}
@@ -163,25 +114,26 @@ class _GymModeState extends ConsumerState<GymMode> {
exercise,
routinesProvider.findById(widget._dayDataGym.day!.routineId),
ratioCompleted,
state.exercisePages,
_totalPages,
exercisePages,
totalPages,
widget._iteration,
),
);
// If there is a rest time, add a countdown timer
if (config.restTime != null) {
out.add(
TimerCountdownWidget(
_controller,
config.restTime!.toInt(),
ratioCompleted,
state.exercisePages,
_totalPages,
),
);
} else {
out.add(TimerWidget(_controller, ratioCompleted, state.exercisePages, _totalPages));
if (state.showTimerPages) {
if (config.restTime != null) {
out.add(
TimerCountdownWidget(
_controller,
config.restTime!.toInt(),
ratioCompleted,
exercisePages,
totalPages,
),
);
} else {
out.add(TimerWidget(_controller, ratioCompleted, exercisePages, totalPages));
}
}
firstPage = false;
@@ -209,14 +161,16 @@ class _GymModeState extends ConsumerState<GymMode> {
}
});
final state = ref.watch(gymStateProvider);
final List<Widget> children = [
StartPage(_controller, widget._dayDataDisplay, _exercisePages),
...getContent(),
StartPage(_controller, widget._dayDataDisplay, state.exercisePages),
..._getContent(state),
SessionPage(
context.read<RoutinesProvider>().findById(widget._dayDataGym.day!.routineId),
_controller,
ref.read(gymStateProvider).startTime,
_exercisePages,
state.startTime,
state.exercisePages,
dayId: widget._dayDataGym.day!.id!,
),
];
@@ -228,6 +182,7 @@ class _GymModeState extends ConsumerState<GymMode> {
// Check if the last page is reached
if (page == children.length - 1) {
widget._logger.finer('Last page reached, clearing gym state');
ref.read(gymStateProvider.notifier).clear();
}
},

View File

@@ -1,12 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/widgets/exercises/images.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class StartPage extends StatelessWidget {
class GymModeOptions extends ConsumerStatefulWidget {
const GymModeOptions({super.key});
@override
ConsumerState<GymModeOptions> createState() => _GymModeOptionsState();
}
class _GymModeOptionsState extends ConsumerState<GymModeOptions> {
bool _showOptions = false;
@override
Widget build(BuildContext context) {
final gymState = ref.watch(gymStateProvider);
final gymNotifier = ref.watch(gymStateProvider.notifier);
final i18n = AppLocalizations.of(context);
return Column(
children: [
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Column(
children: [
SwitchListTile(
title: const Text('Show exercise overview pages'),
value: gymState.showExercisePages,
onChanged: (value) => gymNotifier.setShowExercisePages(value),
),
SwitchListTile(
title: const Text('Show timer'),
value: gymState.showTimerPages,
onChanged: (value) => gymNotifier.setShowTimerPages(value),
),
],
),
),
crossFadeState: _showOptions ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
ListTile(
title: Text(i18n.settingsTitle),
leading: const Icon(Icons.settings),
onTap: () => setState(() => _showOptions = !_showOptions),
),
],
);
}
}
class StartPage extends ConsumerWidget {
final PageController _controller;
final DayData _dayData;
final Map<Exercise, int> _exercisePages;
@@ -14,7 +67,7 @@ class StartPage extends StatelessWidget {
const StartPage(this._controller, this._dayData, this._exercisePages);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
NavigationHeader(
@@ -22,6 +75,7 @@ class StartPage extends StatelessWidget {
_controller,
exercisePages: _exercisePages,
),
Expanded(
child: ListView(
children: [
@@ -73,6 +127,7 @@ class StartPage extends StatelessWidget {
],
),
),
const GymModeOptions(),
FilledButton(
child: Text(AppLocalizations.of(context).start),
onPressed: () {