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
17 changes: 17 additions & 0 deletions lib/features/routines/providers/gym_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
import 'package:wger/core/uuid.dart';
import 'package:wger/features/exercises/models/exercise.dart';
import 'package:wger/features/routines/models/day_data.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';

Expand All @@ -34,6 +35,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_STICKY_SET_VALUES = 'stickySetValuesPrefs';

/// In seconds
const DEFAULT_COUNTDOWN_DURATION = 180;
Expand Down Expand Up @@ -186,6 +188,15 @@ class GymModeState {
final bool showDistinctLogs;
final bool showWorkoutDuration;

/// Whether the log form of a set is pre-filled with the values last
/// logged for the same exercise during this workout, instead of the
/// planned ones. Disabled by default.
final bool stickySetValues;

/// The values last logged per exercise during this workout, used to
/// pre-fill the log form when [stickySetValues] is enabled.
final Map<int, Log> lastLoggedValues;

// Routine data
late final int dayId;
late final int iteration;
Expand All @@ -204,6 +215,8 @@ class GymModeState {
this.logScopeWeeks,
this.showDistinctLogs = true,
this.showWorkoutDuration = true,
this.stickySetValues = false,
this.lastLoggedValues = const {},
int? dayId,
int? iteration,
Routine? routine,
Expand Down Expand Up @@ -248,6 +261,8 @@ class GymModeState {
bool clearLogScopeWeeks = false,
bool? showDistinctLogs,
bool? showWorkoutDuration,
bool? stickySetValues,
Map<int, Log>? lastLoggedValues,
}) {
return GymModeState(
isInitialized: isInitialized ?? this.isInitialized,
Expand All @@ -270,6 +285,8 @@ class GymModeState {
logScopeWeeks: clearLogScopeWeeks ? null : (logScopeWeeks ?? this.logScopeWeeks),
showDistinctLogs: showDistinctLogs ?? this.showDistinctLogs,
showWorkoutDuration: showWorkoutDuration ?? this.showWorkoutDuration,
stickySetValues: stickySetValues ?? this.stickySetValues,
lastLoggedValues: lastLoggedValues ?? this.lastLoggedValues,
);
}

Expand Down
40 changes: 40 additions & 0 deletions lib/features/routines/providers/gym_state_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ class GymStateNotifier extends _$GymStateNotifier {
state = state.copyWith(showWorkoutDuration: showWorkoutDuration);
}

final stickySetValues = await prefs.getBool(PREFS_STICKY_SET_VALUES);
if (stickySetValues != null && stickySetValues != state.stickySetValues) {
state = state.copyWith(stickySetValues: stickySetValues);
}

_logger.finer(
'Loaded saved preferences: '
'showExercise=$showExercise '
Expand Down Expand Up @@ -121,6 +126,7 @@ class GymStateNotifier extends _$GymStateNotifier {
}
await prefs.setBool(PREFS_SHOW_DISTINCT_LOGS, state.showDistinctLogs);
await prefs.setBool(PREFS_SHOW_WORKOUT_DURATION, state.showWorkoutDuration);
await prefs.setBool(PREFS_STICKY_SET_VALUES, state.stickySetValues);

_logger.finer(
'Saved preferences: '
Expand Down Expand Up @@ -319,6 +325,25 @@ class GymStateNotifier extends _$GymStateNotifier {
routineId: state.routine.id,
iteration: state.iteration,
);

// Pre-fill with the values last logged for this exercise during this
// workout (e.g. when dialing in the weight over the first sets), if
// enabled. This intentionally only copies the values: the id, session,
// slot entry and targets stay those of a fresh template.
final lastValues = state.stickySetValues
? state.lastLoggedValues[slotEntryPage.setConfigData!.exerciseId]
: null;
if (lastValues != null) {
log
..repetitions = lastValues.repetitions
..repetitionsUnitId = lastValues.repetitionsUnitId
..repetitionsUnitObj = lastValues.repetitionsUnitObj
..weight = lastValues.weight
..weightUnitId = lastValues.weightUnitId
..weightUnitObj = lastValues.weightUnitObj
..rir = lastValues.rir;
}

ref.read(gymLogProvider.notifier).setLog(log);
}

Expand Down Expand Up @@ -365,6 +390,20 @@ class GymStateNotifier extends _$GymStateNotifier {
_savePrefs();
}

void setStickySetValues(bool value) {
state = state.copyWith(stickySetValues: value);
_savePrefs();
}

/// Remembers the values logged for the log's exercise so that the next
/// set of the exercise can be pre-filled with them, see
/// [GymModeState.stickySetValues].
void recordLoggedValues(Log log) {
final updated = Map<int, Log>.of(state.lastLoggedValues);
updated[log.exerciseId] = log;
state = state.copyWith(lastLoggedValues: updated);
}

void markSlotPageAsDone(String uuid, {required bool isDone}) {
final slotPage = state.getSlotPageByUUID(uuid);
if (slotPage == null) {
Expand Down Expand Up @@ -493,6 +532,7 @@ class GymStateNotifier extends _$GymStateNotifier {
isInitialized: false,
pages: [],
currentPage: 0,
lastLoggedValues: const {},

validUntil: clock.now().add(DEFAULT_DURATION),
workoutStart: clock.now(),
Expand Down
1 change: 1 addition & 0 deletions lib/features/routines/widgets/gym_mode/log_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
}

gymProvider.markSlotPageAsDone(page.uuid, isDone: true);
gymProvider.recordLoggedValues(log);
showSnackbar(
context,
i18n.successfullySaved,
Expand Down
7 changes: 7 additions & 0 deletions lib/features/routines/widgets/gym_mode/start_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ class _GymModeOptionsState extends ConsumerState<GymModeOptions> {
value: gymState.showDistinctLogs,
onChanged: (value) => gymNotifier.setShowDistinctLogs(value),
),
SwitchListTile(
key: const ValueKey('gym-mode-sticky-set-values'),
title: Text(i18n.gymModeStickySetValues),
subtitle: Text(i18n.gymModeStickySetValuesHelp),
value: gymState.stickySetValues,
onChanged: (value) => gymNotifier.setStickySetValues(value),
),
],
),
),
Expand Down
8 changes: 8 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,14 @@
"@pause": {
"description": "Noun, not an imperative! Label used for the pause when using the gym mode"
},
"gymModeStickySetValues": "Sticky set values",
"@gymModeStickySetValues": {
"description": "Label for a gym mode setting: when enabled, the next set of an exercise is pre-filled with the values last logged instead of the planned ones"
},
"gymModeStickySetValuesHelp": "Pre-fill the next set of an exercise with the values (weight, repetitions, RiR) you last logged for it during this workout",
"@gymModeStickySetValuesHelp": {
"description": "Help text for the sticky set values setting in the gym mode"
},
"jumpTo": "Jump to",
"@jumpTo": {
"description": "Imperative. Label used in popup allowing the user to jump to a specific exercise while in the gym mode"
Expand Down
87 changes: 87 additions & 0 deletions test/features/routines/providers/gym_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import 'package:wger/features/account/providers/user_profile_repository.dart';
import 'package:wger/features/exercises/models/exercise.dart';
import 'package:wger/features/routines/models/day.dart';
import 'package:wger/features/routines/models/day_data.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/slot_data.dart';
import 'package:wger/features/routines/providers/gym_log_notifier.dart';
import 'package:wger/features/routines/providers/gym_state.dart';
import 'package:wger/features/routines/providers/gym_state_notifier.dart';
import 'package:wger/features/routines/providers/routines_notifier.dart';
Expand Down Expand Up @@ -508,6 +510,91 @@ void main() {
});
});

group('GymStateNotifier sticky set values', () {
// Page structure of the test routine (exercise + timer pages enabled):
// start(0)
// slot 1 (exercise 1): overview(1), log(2), timer(3), log(4), timer(5), log(6), timer(7)
// slot 2 (exercise 6): overview(8), log(9), ...

Log buildLoggedValues() {
final slotPage = notifier.state.pages[1].slotPages[1];
return Log.fromSetConfigData(slotPage.setConfigData!, routineId: 1, iteration: 1)
..repetitions = 7
..weight = 42.5
..rir = 1;
}

test('Sets the flag and persists it', () async {
// Act
notifier.setStickySetValues(true);
await pumpEventQueue();

// Assert
expect(notifier.state.stickySetValues, true);
expect(await PreferenceHelper.asyncPref.getBool(PREFS_STICKY_SET_VALUES), true);
});

test(
'Pre-fills the next set of the exercise with the last logged values when enabled',
() async {
// Arrange
notifier.setStickySetValues(true);
await pumpEventQueue();
notifier.recordLoggedValues(buildLoggedValues());

// Act: navigate to the second set of the same exercise
notifier.setCurrentPage(4);

// Assert
final gymLog = container.read(gymLogProvider)!;
expect(gymLog.repetitions, 7);
expect(gymLog.weight, 42.5);
expect(gymLog.rir, 1);
expect(gymLog.id, isNull, reason: 'A fresh log is seeded, not a copy of the saved one');
},
);

test('Pre-fills with the planned values when disabled', () {
// Arrange
notifier.recordLoggedValues(buildLoggedValues());

// Act
notifier.setCurrentPage(4);

// Assert: the values of the set configuration
final gymLog = container.read(gymLogProvider)!;
expect(gymLog.repetitions, 3);
expect(gymLog.weight, 100);
});

test('Does not carry values over to a different exercise', () async {
// Arrange
notifier.setStickySetValues(true);
await pumpEventQueue();
notifier.recordLoggedValues(buildLoggedValues());

// Act: navigate to the first set of the second exercise
notifier.setCurrentPage(9);

// Assert: the values of that exercise's set configuration
final gymLog = container.read(gymLogProvider)!;
expect(gymLog.repetitions, 12);
expect(gymLog.weight, 10);
});

test('Clearing the state forgets the logged values', () {
// Arrange
notifier.recordLoggedValues(buildLoggedValues());
expect(notifier.state.lastLoggedValues, isNotEmpty);

// Act
notifier.clear();

// Assert
expect(notifier.state.lastLoggedValues, isEmpty);
});
});

group('GymStateNotifier.startWorkout', () {
test('Resets the workout start time to now', () {
// Arrange
Expand Down
29 changes: 29 additions & 0 deletions test/features/routines/widgets/gym_mode/log_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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/slot_data.dart';
import 'package:wger/features/routines/providers/gym_log_notifier.dart';
import 'package:wger/features/routines/providers/gym_state.dart';
import 'package:wger/features/routines/providers/gym_state_notifier.dart';
import 'package:wger/features/routines/providers/workout_logs_repository.dart';
Expand Down Expand Up @@ -244,6 +245,34 @@ void main() {
expect(saved.iteration, gymState.iteration);
});

testWidgets('carries the logged values over to the next set when sticky values are enabled', (
tester,
) async {
seedLogPage(testdata.getTestRoutine());
container.read(gymStateProvider.notifier).setStickySetValues(true);
await pumpLogPage(tester);

// Log a set with non-default values
final fields = find.byType(TextFormField);
await tester.enterText(fields.at(0), '12'); // reps
await tester.enterText(fields.at(1), '34'); // weight
await tester.pump();
await tester.tap(find.byKey(const ValueKey('save-log-button')));
await tester.pumpAndSettle();

// Navigate to the next set of the same exercise
container.read(gymStateProvider.notifier).setCurrentPage(4);
await tester.pumpAndSettle();

// The form is pre-filled with the values just logged, not the planned ones
final gymLog = container.read(gymLogProvider)!;
expect(gymLog.repetitions, 12);
expect(gymLog.weight, 34);
expect(gymLog.id, isNull, reason: 'A fresh log is seeded, not a copy of the saved one');
final repField = tester.widget<EditableText>(find.byType(EditableText).at(0));
expect(repField.controller.text, '12');
});

testWidgets('reps quick buttons increment and decrement the value', (tester) async {
final routine = testdata.getTestRoutine();
routine.dayDataGym[0].slots[0].setConfigs[0].repetitions = 0;
Expand Down
8 changes: 8 additions & 0 deletions test/features/routines/widgets/gym_mode/start_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,19 @@ void main() {
await tester.tap(durationSwitch);
await tester.pump();

// Toggle sticky set values
final stickySwitch = find.byKey(const ValueKey('gym-mode-sticky-set-values'));
expect(stickySwitch, findsOneWidget);
await tester.ensureVisible(stickySwitch);
await tester.tap(stickySwitch);
await tester.pump();

final notifier = container.read(gymStateProvider.notifier);
expect(notifier.state.showExercisePages, isFalse);
expect(notifier.state.showTimerPages, isFalse);
expect(notifier.state.alertOnCountdownEnd, isTrue);
expect(notifier.state.showWorkoutDuration, isFalse);
expect(notifier.state.stickySetValues, isTrue);
});

testWidgets('Dropdown, text field and refresh button update notifier state', (tester) async {
Expand Down