From 01e22be8052974aad58a302a7983cc20e00be74d Mon Sep 17 00:00:00 2001 From: Brian Hotopp Date: Tue, 28 Jul 2026 17:41:24 -0400 Subject: [PATCH] Add optional sticky set values to the gym mode When dialing in the weight over the first sets of an exercise, the log form of every following set is pre-filled with the planned values again, so any adjustment has to be re-entered for every set. When the new "sticky set values" setting is enabled, the values (weight, repetitions, RiR, units) logged for a set are remembered and the next set of the same exercise is pre-filled with them instead of the planned ones. This intentionally only copies the values: the id, session, slot entry and targets stay those of a fresh template, so saving still creates a new log row for the correct slot. The setting is disabled by default and can be toggled in the gym mode options; it is persisted like the other gym mode settings. The remembered values are cleared when the workout ends. --- .../routines/providers/gym_state.dart | 17 ++++ .../providers/gym_state_notifier.dart | 40 +++++++++ .../routines/widgets/gym_mode/log_page.dart | 1 + .../routines/widgets/gym_mode/start_page.dart | 7 ++ lib/l10n/app_en.arb | 8 ++ .../routines/providers/gym_state_test.dart | 87 +++++++++++++++++++ .../widgets/gym_mode/log_page_test.dart | 29 +++++++ .../widgets/gym_mode/start_page_test.dart | 8 ++ 8 files changed, 197 insertions(+) diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index ffb5f9b27..1f4b905eb 100644 --- a/lib/features/routines/providers/gym_state.dart +++ b/lib/features/routines/providers/gym_state.dart @@ -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'; @@ -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; @@ -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 lastLoggedValues; + // Routine data late final int dayId; late final int iteration; @@ -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, @@ -248,6 +261,8 @@ class GymModeState { bool clearLogScopeWeeks = false, bool? showDistinctLogs, bool? showWorkoutDuration, + bool? stickySetValues, + Map? lastLoggedValues, }) { return GymModeState( isInitialized: isInitialized ?? this.isInitialized, @@ -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, ); } diff --git a/lib/features/routines/providers/gym_state_notifier.dart b/lib/features/routines/providers/gym_state_notifier.dart index 83e4bd186..d1032ac4b 100644 --- a/lib/features/routines/providers/gym_state_notifier.dart +++ b/lib/features/routines/providers/gym_state_notifier.dart @@ -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 ' @@ -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: ' @@ -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); } @@ -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.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) { @@ -493,6 +532,7 @@ class GymStateNotifier extends _$GymStateNotifier { isInitialized: false, pages: [], currentPage: 0, + lastLoggedValues: const {}, validUntil: clock.now().add(DEFAULT_DURATION), workoutStart: clock.now(), diff --git a/lib/features/routines/widgets/gym_mode/log_page.dart b/lib/features/routines/widgets/gym_mode/log_page.dart index 6226bee1f..b968d562d 100644 --- a/lib/features/routines/widgets/gym_mode/log_page.dart +++ b/lib/features/routines/widgets/gym_mode/log_page.dart @@ -399,6 +399,7 @@ class _LogFormWidgetState extends ConsumerState { } gymProvider.markSlotPageAsDone(page.uuid, isDone: true); + gymProvider.recordLoggedValues(log); showSnackbar( context, i18n.successfullySaved, diff --git a/lib/features/routines/widgets/gym_mode/start_page.dart b/lib/features/routines/widgets/gym_mode/start_page.dart index 471a113cc..5766f011b 100644 --- a/lib/features/routines/widgets/gym_mode/start_page.dart +++ b/lib/features/routines/widgets/gym_mode/start_page.dart @@ -196,6 +196,13 @@ class _GymModeOptionsState extends ConsumerState { 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), + ), ], ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1aff62662..18fc05b9a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" diff --git a/test/features/routines/providers/gym_state_test.dart b/test/features/routines/providers/gym_state_test.dart index cb7894b3f..a841578d4 100644 --- a/test/features/routines/providers/gym_state_test.dart +++ b/test/features/routines/providers/gym_state_test.dart @@ -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'; @@ -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 diff --git a/test/features/routines/widgets/gym_mode/log_page_test.dart b/test/features/routines/widgets/gym_mode/log_page_test.dart index d941ab314..041945ff8 100644 --- a/test/features/routines/widgets/gym_mode/log_page_test.dart +++ b/test/features/routines/widgets/gym_mode/log_page_test.dart @@ -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'; @@ -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(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; diff --git a/test/features/routines/widgets/gym_mode/start_page_test.dart b/test/features/routines/widgets/gym_mode/start_page_test.dart index dcc9105fe..30ee77a8a 100644 --- a/test/features/routines/widgets/gym_mode/start_page_test.dart +++ b/test/features/routines/widgets/gym_mode/start_page_test.dart @@ -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 {