From f1f37d3e03b6ef27f118fa1671bde533dc14ccea Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 15 Nov 2025 01:32:59 +0100 Subject: [PATCH] Allow swapping exercises in the gym mode --- lib/models/workouts/routine.dart | 34 +++ lib/models/workouts/set_config_data.dart | 52 ++++ lib/providers/gym_state.dart | 37 +++ lib/widgets/routines/gym_mode/navigation.dart | 4 - .../routines/gym_mode/workout_menu.dart | 252 ++++++++++++------ test/routine/gym_mode_provider_test.dart | 33 ++- 6 files changed, 318 insertions(+), 94 deletions(-) diff --git a/lib/models/workouts/routine.dart b/lib/models/workouts/routine.dart index bd90d6bb..3164ab7e 100644 --- a/lib/models/workouts/routine.dart +++ b/lib/models/workouts/routine.dart @@ -19,6 +19,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:wger/helpers/date.dart'; import 'package:wger/helpers/json.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/models/workouts/log.dart'; @@ -176,4 +177,37 @@ class Routine { return groupedLogs; } + + void replaceExercise(int oldExerciseId, Exercise newExercise) { + for (final session in sessions) { + for (final log in session.logs) { + if (log.exerciseId == oldExerciseId) { + log.exerciseId = newExercise.id!; + log.exercise = newExercise; + } + } + } + + for (final day in dayData) { + for (final slot in day.slots) { + for (final config in slot.setConfigs) { + if (config.exerciseId == oldExerciseId) { + config.exerciseId = newExercise.id!; + config.exercise = newExercise; + } + } + } + } + + for (final day in dayDataGym) { + for (final slot in day.slots) { + for (final config in slot.setConfigs) { + if (config.exerciseId == oldExerciseId) { + config.exerciseId = newExercise.id!; + config.exercise = newExercise; + } + } + } + } + } } diff --git a/lib/models/workouts/set_config_data.dart b/lib/models/workouts/set_config_data.dart index bdebc47c..ed66195a 100644 --- a/lib/models/workouts/set_config_data.dart +++ b/lib/models/workouts/set_config_data.dart @@ -135,6 +135,58 @@ class SetConfigData { } } + SetConfigData copyWith({ + int? exerciseId, + int? slotEntryId, + SlotEntryType? type, + String? textRepr, + num? nrOfSets, + num? maxNrOfSets, + num? weight, + num? maxWeight, + int? weightUnitId, + num? weightRounding, + num? repetitions, + num? maxRepetitions, + int? repetitionsUnitId, + num? repetitionsRounding, + num? rir, + num? maxRir, + num? rpe, + num? restTime, + num? maxRestTime, + String? comment, + Exercise? exercise, + WeightUnit? weightUnit, + RepetitionUnit? repetitionsUnit, + }) { + return SetConfigData( + exerciseId: exerciseId ?? this.exerciseId, + slotEntryId: slotEntryId ?? this.slotEntryId, + type: type ?? this.type, + textRepr: textRepr ?? this.textRepr, + nrOfSets: nrOfSets ?? this.nrOfSets, + maxNrOfSets: maxNrOfSets ?? this.maxNrOfSets, + weight: weight ?? this.weight, + maxWeight: maxWeight ?? this.maxWeight, + weightUnitId: weightUnitId ?? this.weightUnitId, + weightRounding: weightRounding ?? this.weightRounding, + repetitions: repetitions ?? this.repetitions, + maxRepetitions: maxRepetitions ?? this.maxRepetitions, + repetitionsUnitId: repetitionsUnitId ?? this.repetitionsUnitId, + repetitionsRounding: repetitionsRounding ?? this.repetitionsRounding, + rir: rir ?? this.rir, + maxRir: maxRir ?? this.maxRir, + rpe: rpe ?? this.rpe, + restTime: restTime ?? this.restTime, + maxRestTime: maxRestTime ?? this.maxRestTime, + comment: comment ?? this.comment, + exercise: exercise ?? this.exercise, + weightUnit: weightUnit ?? this.weightUnit, + repetitionsUnit: repetitionsUnit ?? this.repetitionsUnit, + ); + } + // Boilerplate factory SetConfigData.fromJson(Map json) => _$SetConfigDataFromJson(json); diff --git a/lib/providers/gym_state.dart b/lib/providers/gym_state.dart index 28a0e776..4bc7c102 100644 --- a/lib/providers/gym_state.dart +++ b/lib/providers/gym_state.dart @@ -420,6 +420,43 @@ class GymStateNotifier extends _$GymStateNotifier { _logger.fine('Set logDone=$isDone for slot page UUID $uuid'); } + void replaceExercises( + String pageEntryUUID, { + required int originalExerciseId, + required Exercise newExercise, + }) { + final updatedPages = state.pages.map((page) { + if (page.type != PageType.set) { + return page; + } + + if (page.uuid != pageEntryUUID) { + return page; + } + + final updatedSlotPages = page.slotPages.map((slotPage) { + if (slotPage.setConfigData != null && + slotPage.setConfigData!.exercise.id == originalExerciseId) { + final updatedSetConfigData = slotPage.setConfigData!.copyWith( + exerciseId: newExercise.id, + exercise: newExercise, + ); + return slotPage.copyWith(setConfigData: updatedSetConfigData); + } + return slotPage; + }).toList(); + + return page.copyWith(slotPages: updatedSlotPages); + }).toList(); + + // TODO: this should not be done in-place! + state.routine.replaceExercise(originalExerciseId, newExercise); + state = state.copyWith( + pages: updatedPages, + ); + _logger.fine('Replaced exercise $originalExerciseId with ${newExercise.id}'); + } + void clear() { _logger.fine('Clearing state'); state = state.copyWith( diff --git a/lib/widgets/routines/gym_mode/navigation.dart b/lib/widgets/routines/gym_mode/navigation.dart index 4f4074c7..cffbb904 100644 --- a/lib/widgets/routines/gym_mode/navigation.dart +++ b/lib/widgets/routines/gym_mode/navigation.dart @@ -18,10 +18,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:provider/provider.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/providers/exercises.dart'; import 'package:wger/providers/gym_state.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/routines/gym_mode/workout_menu.dart'; @@ -87,8 +85,6 @@ class NavigationHeader extends ConsumerWidget { const NavigationHeader(this._title, this._controller, {this.showEndWorkoutButton = true}); Widget getDialog(BuildContext context, int totalPages, List pages) { - final exercisesProvider = context.read(); - final endWorkoutButton = showEndWorkoutButton ? TextButton( child: Text(AppLocalizations.of(context).endWorkout), diff --git a/lib/widgets/routines/gym_mode/workout_menu.dart b/lib/widgets/routines/gym_mode/workout_menu.dart index 8419e54f..c46fc766 100644 --- a/lib/widgets/routines/gym_mode/workout_menu.dart +++ b/lib/widgets/routines/gym_mode/workout_menu.dart @@ -18,8 +18,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/providers/gym_state.dart'; +import 'package:wger/widgets/exercises/autocompleter.dart'; class WorkoutMenu extends StatelessWidget { final PageController _controller; @@ -49,6 +51,14 @@ class WorkoutMenu extends StatelessWidget { ), ), ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Swapping an exercise only affects the current workout, no changes are saved.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), ], ), ); @@ -97,100 +107,180 @@ class NavigationTab extends ConsumerWidget { } } -class ProgressionTab extends ConsumerWidget { +class ProgressionTab extends ConsumerStatefulWidget { + final _logger = Logger('ProgressionTab'); final PageController _controller; - const ProgressionTab(this._controller); + ProgressionTab(this._controller, {super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ProgressionTabState(); +} + +class _ProgressionTabState extends ConsumerState { + String? showDetailsForPageId; + _ProgressionTabState(); + + @override + Widget build(BuildContext context) { final state = ref.watch(gymStateProvider); final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Column( - children: [ - ...state.pages.where((page) => page.type == PageType.set).map((page) { - return Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...page.slotPages.where((slotPage) => slotPage.type == SlotPageType.log).map( - ( - slotPage, - ) { - final exercise = slotPage.setConfigData!.exercise - .getTranslation( - Localizations.localeOf(context).languageCode, - ) - .name; + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + children: [ + ...state.pages.where((page) => page.type == PageType.set).map((page) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...page.slotPages.where((slotPage) => slotPage.type == SlotPageType.log).map( + ( + slotPage, + ) { + final exercise = slotPage.setConfigData!.exercise + .getTranslation( + Localizations.localeOf(context).languageCode, + ) + .name; - // Sets that are done are marked with a strikethrough - final decoration = slotPage.logDone - ? TextDecoration.lineThrough - : TextDecoration.none; + // Sets that are done are marked with a strikethrough + final decoration = slotPage.logDone + ? TextDecoration.lineThrough + : TextDecoration.none; - // Sets that are done have a lighter color - final color = slotPage.logDone - ? theme.colorScheme.onSurface.withValues(alpha: 0.6) - : null; + // Sets that are done have a lighter color + final color = slotPage.logDone + ? theme.colorScheme.onSurface.withValues(alpha: 0.6) + : null; - // he row for the current page is highlighted in bold - final fontWeight = state.currentPage == slotPage.pageIndex - ? FontWeight.bold - : null; + // The row for the current page is highlighted in bold + final fontWeight = state.currentPage == slotPage.pageIndex + ? FontWeight.bold + : null; - return Text.rich( - TextSpan( - children: [ - if (slotPage.logDone) const TextSpan(text: '✅ '), - TextSpan( - text: '$exercise - ${slotPage.setConfigData!.textReprWithType}', - style: theme.textTheme.bodyMedium!.copyWith( - decoration: decoration, - fontWeight: fontWeight, - color: color, + return Text.rich( + TextSpan( + children: [ + if (slotPage.logDone) const TextSpan(text: '✅ '), + TextSpan( + text: '$exercise - ${slotPage.setConfigData!.textReprWithType}', + style: theme.textTheme.bodyMedium!.copyWith( + decoration: decoration, + fontWeight: fontWeight, + color: color, + ), ), - ), - ], + ], + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + }, + ), + + if (showDetailsForPageId == page.uuid) ExerciseSwapWidget(page.uuid), + Row( + mainAxisSize: MainAxisSize.max, + //mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () { + if (showDetailsForPageId == page.uuid) { + setState(() { + widget._logger.fine('Hiding details'); + showDetailsForPageId = null; + }); + } else { + setState(() { + widget._logger.fine('Showing details for page ${page.uuid}'); + showDetailsForPageId = page.uuid; + }); + } + }, + icon: Icon( + showDetailsForPageId == page.uuid + ? Icons.change_circle + : Icons.change_circle_outlined, + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ); - }, - ), - Row( - mainAxisSize: MainAxisSize.max, - //mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.swap_horiz, size: 18), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.add, size: 18), - ), - Expanded(child: Container()), - IconButton( - onPressed: () { - _controller.animateToPage( - page.pageIndex, - duration: DEFAULT_ANIMATION_DURATION, - curve: DEFAULT_ANIMATION_CURVE, - ); - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - const SizedBox(height: 8), - ], - ); - }), - ], + // IconButton( + // onPressed: () {}, + // icon: const Icon(Icons.add), + // ), + Expanded(child: Container()), + IconButton( + onPressed: () { + widget._controller.animateToPage( + page.pageIndex, + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + const SizedBox(height: 8), + ], + ); + }), + ], + ), + ), + ); + } +} + +class ExerciseSwapWidget extends ConsumerWidget { + final _logger = Logger('ExerciseSwapWidget'); + + final String pageUUID; + + ExerciseSwapWidget(this.pageUUID, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gymStateProvider); + final gymProvider = ref.read(gymStateProvider.notifier); + final page = state.pages.firstWhere((p) => p.uuid == pageUUID); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Card( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + ...page.exercises.map((e) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Text( + e.getTranslation('en').name, + style: Theme.of(context).textTheme.bodyLarge, + ), + const Icon(Icons.swap_vert), + ExerciseAutocompleter( + onExerciseSelected: (exercise) { + gymProvider.replaceExercises( + page.uuid, + originalExerciseId: e.id!, + newExercise: exercise, + ); + _logger.fine('Replaced exercise ${e.id} with ${exercise.id}'); + }, + ), + const SizedBox(height: 10), + ], + ); + }), + ], + ), + ), ), ); } diff --git a/test/routine/gym_mode_provider_test.dart b/test/routine/gym_mode_provider_test.dart index d6badeb6..33e6c318 100644 --- a/test/routine/gym_mode_provider_test.dart +++ b/test/routine/gym_mode_provider_test.dart @@ -18,7 +18,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:wger/models/workouts/day_data.dart'; import 'package:wger/providers/gym_state.dart'; import '../../test_data/routines.dart'; @@ -27,22 +26,26 @@ void main() { group('GymStateNotifier.calculatePages', () { late GymStateNotifier notifier; late ProviderContainer container; - late DayData day; setUp(() { container = ProviderContainer.test(); notifier = container.read(gymStateProvider.notifier); - day = getTestRoutine().dayData.first; }); test( 'Correctly generates pages: exercise and timer', () { // Arrange - notifier.state = notifier.state.copyWith(showExercisePages: true, showTimerPages: true); + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: true, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); // Act - notifier.calculatePages(day); + notifier.calculatePages(); // Assert final pages = notifier.state.pages; @@ -61,10 +64,16 @@ void main() { test('Correctly generates pages: no exercises and no timer', () { // Arrange - notifier.state = notifier.state.copyWith(showExercisePages: false, showTimerPages: false); + notifier.state = notifier.state.copyWith( + showExercisePages: false, + showTimerPages: false, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); // Act - notifier.calculatePages(day); + notifier.calculatePages(); // Assert final pages = notifier.state.pages; @@ -78,10 +87,16 @@ void main() { test('Correctly generates pages: exercises and no timer', () { // Arrange - notifier.state = notifier.state.copyWith(showExercisePages: true, showTimerPages: false); + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: false, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); // Act - notifier.calculatePages(day); + notifier.calculatePages(); // Assert final pages = notifier.state.pages;