Allow swapping exercises in the gym mode

This commit is contained in:
Roland Geider
2025-11-15 01:32:59 +01:00
parent 03cd945bfc
commit f1f37d3e03
6 changed files with 318 additions and 94 deletions

View File

@@ -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;
}
}
}
}
}
}

View File

@@ -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<String, dynamic> json) => _$SetConfigDataFromJson(json);

View File

@@ -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(

View File

@@ -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<PageEntry> pages) {
final exercisesProvider = context.read<ExercisesProvider>();
final endWorkoutButton = showEndWorkoutButton
? TextButton(
child: Text(AppLocalizations.of(context).endWorkout),

View File

@@ -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<ProgressionTab> createState() => _ProgressionTabState();
}
class _ProgressionTabState extends ConsumerState<ProgressionTab> {
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),
],
);
}),
],
),
),
),
);
}