From 72a983bd81ff8c1ed8d121448bc25e4219605fda Mon Sep 17 00:00:00 2001 From: Brian Hotopp Date: Tue, 28 Jul 2026 17:02:33 -0400 Subject: [PATCH 1/2] Show the upcoming set on the gym mode timer pages The timer pages only showed the countdown, wasting the whole rest period: to prepare for the next set (e.g. walk over and preload the plates) the user had to swipe forward to see what comes next, which also restarted the timer. The timers now show the upcoming set below the time: exercise name, set configuration (reps, weight, etc.) and set counter. This is always the next log page, which can be the next set of the same exercise (e.g. when progressing weights within an exercise) or the first set of the next exercise. After the last set of the day nothing is shown. --- .../routines/providers/gym_state.dart | 15 +++ .../routines/widgets/gym_mode/gym_mode.dart | 3 +- .../routines/widgets/gym_mode/timer.dart | 92 ++++++++++++++-- lib/l10n/app_en.arb | 4 + .../routines/providers/gym_state_test.dart | 28 +++++ .../routines/widgets/gym_mode/timer_test.dart | 104 ++++++++++++++++++ 6 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 test/features/routines/widgets/gym_mode/timer_test.dart diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index ffb5f9b27..f43686b03 100644 --- a/lib/features/routines/providers/gym_state.dart +++ b/lib/features/routines/providers/gym_state.dart @@ -326,6 +326,21 @@ class GymModeState { return null; } + /// Returns the next log page after the slot page at [pageIndex], i.e. the + /// set that will be performed once the current rest timer ends. + /// + /// This deliberately returns the next *set*, which may belong to the same + /// exercise (e.g. when progressing weights within an exercise) or to the + /// next one. Returns null when the workout has no further sets. + SlotPageEntry? getNextLogPage(int pageIndex) { + for (final slotPage in pages.expand((p) => p.slotPages)) { + if (slotPage.pageIndex > pageIndex && slotPage.type == SlotPageType.log) { + return slotPage; + } + } + return null; + } + double get ratioCompleted { if (totalPages == 0) { return 0.0; diff --git a/lib/features/routines/widgets/gym_mode/gym_mode.dart b/lib/features/routines/widgets/gym_mode/gym_mode.dart index 7888972bb..dcc709068 100644 --- a/lib/features/routines/widgets/gym_mode/gym_mode.dart +++ b/lib/features/routines/widgets/gym_mode/gym_mode.dart @@ -132,8 +132,9 @@ class _GymModeState extends ConsumerState { ? TimerCountdownWidget( _controller, (rest ?? gymState.countdownDuration.inSeconds).toInt(), + slotPage.uuid, ) - : TimerWidget(_controller), + : TimerWidget(_controller, slotPage.uuid), ); } } diff --git a/lib/features/routines/widgets/gym_mode/timer.dart b/lib/features/routines/widgets/gym_mode/timer.dart index efe21e26f..e1b807606 100644 --- a/lib/features/routines/widgets/gym_mode/timer.dart +++ b/lib/features/routines/widgets/gym_mode/timer.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.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/widgets/gym_mode/navigation.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -28,7 +29,12 @@ import 'package:wger/l10n/generated/app_localizations.dart'; class TimerWidget extends StatefulWidget { final PageController _controller; - const TimerWidget(this._controller); + /// Identifies which slot page this widget renders, so it shows its own + /// up-next information instead of whatever the globally-current page + /// happens to be. + final String slotUuid; + + const TimerWidget(this._controller, this.slotUuid); @override _TimerWidgetState createState() => _TimerWidgetState(); @@ -71,13 +77,18 @@ class _TimerWidgetState extends State { widget._controller, ), Expanded( - child: Center( - child: Text( - DateFormat('m:ss').format(displayTime), - style: Theme.of( - context, - ).textTheme.displayLarge!.copyWith(color: Theme.of(context).colorScheme.primary), - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('m:ss').format(displayTime), + style: Theme.of( + context, + ).textTheme.displayLarge!.copyWith(color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 16), + UpNextWidget(widget.slotUuid), + ], ), ), NavigationFooter(widget._controller), @@ -90,9 +101,15 @@ class TimerCountdownWidget extends ConsumerStatefulWidget { final PageController _controller; final int _seconds; + /// Identifies which slot page this widget renders, so it shows its own + /// up-next information instead of whatever the globally-current page + /// happens to be. + final String slotUuid; + const TimerCountdownWidget( this._controller, this._seconds, + this.slotUuid, ); @override @@ -161,6 +178,7 @@ class _TimerCountdownWidgetState extends ConsumerState { ).textTheme.displayLarge!.copyWith(color: Theme.of(context).colorScheme.primary), ), const SizedBox(height: 16), + UpNextWidget(widget.slotUuid), ], ), ), @@ -169,3 +187,61 @@ class _TimerCountdownWidgetState extends ConsumerState { ); } } + +/// Shows the set that will be performed once the rest timer on the slot page +/// identified by [slotUuid] ends. This can be the next set of the same +/// exercise (e.g. when progressing weights within an exercise) or the first +/// set of the next exercise. Renders nothing for the last rest of the day. +class UpNextWidget extends ConsumerWidget { + final String slotUuid; + + const UpNextWidget(this.slotUuid, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final gymState = ref.watch(gymStateProvider); + + final currentSlotPage = gymState.getSlotPageByUUID(slotUuid); + final nextSlotPage = currentSlotPage == null + ? null + : gymState.getNextLogPage(currentSlotPage.pageIndex); + if (nextSlotPage == null) { + return const SizedBox.shrink(); + } + + final setConfigData = nextSlotPage.setConfigData!; + final exerciseName = setConfigData.exercise + .getTranslation(Localizations.localeOf(context).languageCode) + .name; + final nrOfLogPages = gymState + .getPageByIndex(nextSlotPage.pageIndex) + ?.slotPages + .where((e) => e.type == SlotPageType.log) + .length; + + return Column( + children: [ + Text( + AppLocalizations.of(context).upNext, + style: theme.textTheme.titleMedium, + ), + Text( + exerciseName, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall, + ), + Text( + setConfigData.textReprWithType, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith(color: theme.colorScheme.primary), + ), + if (nrOfLogPages != null) + Text( + '${nextSlotPage.setIndex + 1} / $nrOfLogPages', + style: theme.textTheme.bodyLarge, + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1aff62662..a3d76eaf9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -407,6 +407,10 @@ "@pause": { "description": "Noun, not an imperative! Label used for the pause when using the gym mode" }, + "upNext": "Up next", + "@upNext": { + "description": "Label shown on the rest timer pages in the gym mode above the information about the upcoming set" + }, "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..a0b95bf7d 100644 --- a/test/features/routines/providers/gym_state_test.dart +++ b/test/features/routines/providers/gym_state_test.dart @@ -625,4 +625,32 @@ void main() { expect(state.copyWith(clearLogScopeWeeks: true).logScopeWeeks, isNull); }); }); + + group('GymModeState.getNextLogPage', () { + // 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), timer(10), log(11), timer(12), log(13), timer(14) + // session(15), summary(16) + + test('Returns the next set of the same exercise for a rest between sets', () { + final next = notifier.state.getNextLogPage(3); + + expect(next!.type, SlotPageType.log); + expect(next.pageIndex, 4); + expect(next.setConfigData!.exerciseId, getTestExercises()[0].id); + }); + + test('Returns the first set of the next exercise after the final rest of an exercise', () { + final next = notifier.state.getNextLogPage(7); + + expect(next!.type, SlotPageType.log); + expect(next.pageIndex, 9); + expect(next.setConfigData!.exerciseId, getTestExercises()[5].id); + }); + + test('Returns null after the last set of the day', () { + expect(notifier.state.getNextLogPage(14), isNull); + }); + }); } diff --git a/test/features/routines/widgets/gym_mode/timer_test.dart b/test/features/routines/widgets/gym_mode/timer_test.dart new file mode 100644 index 000000000..6e5cfef95 --- /dev/null +++ b/test/features/routines/widgets/gym_mode/timer_test.dart @@ -0,0 +1,104 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * wger Workout Manager is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.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/widgets/gym_mode/timer.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +import '../../../../../test_data/routines.dart' as testdata; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Timer up-next', () { + late ProviderContainer container; + + // Page structure of the test routine (exercise + timer pages enabled): + // start(0) + // slot 1 (bench press): overview(1), log(2), timer(3), log(4), timer(5), log(6), timer(7) + // slot 2 (side raises): overview(8), log(9), timer(10), log(11), timer(12), log(13), timer(14) + // session(15), summary(16) + setUp(() { + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + container = ProviderContainer.test(); + final notifier = container.read(gymStateProvider.notifier); + final routine = testdata.getTestRoutine(); + notifier.initData(routine, routine.days.first.id!, 1); + }); + + Future pumpTimer(WidgetTester tester, int timerPageIndex) async { + final slotPage = container.read(gymStateProvider).getSlotEntryPageByIndex(timerPageIndex)!; + expect(slotPage.type, SlotPageType.timer); + + // No pumpAndSettle: the widgets keep a periodic UI timer alive, which + // would never settle. + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + final controller = PageController(); + return PageView( + controller: controller, + children: [TimerCountdownWidget(controller, 90, slotPage.uuid)], + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('shows the next set of the same exercise for a rest between sets', (tester) async { + await pumpTimer(tester, 3); + + expect(find.text('Up next'), findsOneWidget); + expect(find.text('Bench press'), findsOneWidget); + expect(find.text('3x100kg'), findsOneWidget); + expect(find.text('2 / 3'), findsOneWidget); + }); + + testWidgets('shows the next exercise after the final rest of an exercise', (tester) async { + await pumpTimer(tester, 7); + + expect(find.text('Up next'), findsOneWidget); + expect(find.text('Side raises'), findsOneWidget); + expect(find.text('12x10kg'), findsOneWidget); + expect(find.text('1 / 3'), findsOneWidget); + }); + + testWidgets('shows nothing after the last set of the day', (tester) async { + await pumpTimer(tester, 14); + + expect(find.text('Up next'), findsNothing); + }); + }); +} From e226c53084c1b0058e189a36a9af27481cd5d222 Mon Sep 17 00:00:00 2001 From: Brian Hotopp Date: Tue, 28 Jul 2026 17:03:44 -0400 Subject: [PATCH 2/2] Allow correcting logged sets during the workout and persist rest timers Two related fixes for navigating back during gym mode: * Re-visiting an already logged page showed the set-config template again and saving inserted a second, duplicate log row. The saved log is now kept on the slot page and the form is seeded from it, so the previously entered values are shown and saving updates the existing row instead of duplicating it (e.g. adding a forgotten RiR). * The rest timers kept their start time in the widget, so leaving the page disposed it and restarted the countdown on return. The start time now lives in the gym state and both timers derive the elapsed or remaining time from it, so the countdown continues while the user e.g. goes back to correct a set. Revisiting an already expired countdown also no longer triggers the end-of-countdown alert again. --- .../routines/providers/gym_log_notifier.dart | 7 ++ .../routines/providers/gym_state.dart | 17 +++++ .../providers/gym_state_notifier.dart | 55 +++++++++++--- .../routines/widgets/gym_mode/log_page.dart | 16 +++- .../routines/widgets/gym_mode/timer.dart | 52 ++++++++++--- .../routines/providers/gym_state_test.dart | 76 +++++++++++++++++++ .../widgets/gym_mode/log_page_test.dart | 45 ++++++++++- .../routines/widgets/gym_mode/timer_test.dart | 27 +++++++ 8 files changed, 274 insertions(+), 21 deletions(-) diff --git a/lib/features/routines/providers/gym_log_notifier.dart b/lib/features/routines/providers/gym_log_notifier.dart index 5bbf5bd5f..92dc2f99f 100644 --- a/lib/features/routines/providers/gym_log_notifier.dart +++ b/lib/features/routines/providers/gym_log_notifier.dart @@ -51,6 +51,13 @@ class GymLogNotifier extends _$GymLogNotifier { state = out; } + /// Starts editing a log that was already persisted during the current + /// workout. Unlike [setLog], the id, session and date are kept so saving + /// updates the existing row instead of inserting a duplicate. + void editLog(Log log) { + state = log; + } + void setWeight(num weight) { state = state?.copyWith(weight: weight); } diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index f43686b03..f0d01e64b 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'; @@ -118,6 +119,16 @@ class SlotPageEntry { /// Whether the log page has been marked as done final bool logDone; + /// The log saved from this page during the current workout, if any. Kept + /// so revisiting the page pre-fills the form with the saved values and + /// saving updates the row instead of inserting a duplicate. + final Log? loggedEntry; + + /// When the timer of this page was started. Lives in the state (not the + /// timer widget) so the countdown continues when the user navigates away + /// and back. + final DateTime? timerStartedAt; + /// The associated SetConfigData final SetConfigData? setConfigData; @@ -127,6 +138,8 @@ class SlotPageEntry { required this.setIndex, this.setConfigData, this.logDone = false, + this.loggedEntry, + this.timerStartedAt, String? uuid, }) : assert( type != SlotPageType.log || setConfigData != null, @@ -142,6 +155,8 @@ class SlotPageEntry { int? pageIndex, SetConfigData? setConfigData, bool? logDone, + Log? loggedEntry, + DateTime? timerStartedAt, }) { return SlotPageEntry( uuid: uuid ?? this.uuid, @@ -150,6 +165,8 @@ class SlotPageEntry { pageIndex: pageIndex ?? this.pageIndex, setConfigData: setConfigData ?? this.setConfigData, logDone: logDone ?? this.logDone, + loggedEntry: loggedEntry ?? this.loggedEntry, + timerStartedAt: timerStartedAt ?? this.timerStartedAt, ); } diff --git a/lib/features/routines/providers/gym_state_notifier.dart b/lib/features/routines/providers/gym_state_notifier.dart index 83e4bd186..21e0e5445 100644 --- a/lib/features/routines/providers/gym_state_notifier.dart +++ b/lib/features/routines/providers/gym_state_notifier.dart @@ -314,6 +314,15 @@ class GymStateNotifier extends _$GymStateNotifier { return; } + // Re-visiting a page that was already logged in this workout: seed the + // form with the saved entry (keeping its id and session) so saving + // updates the row instead of inserting a duplicate. + final loggedEntry = slotEntryPage.loggedEntry; + if (loggedEntry != null) { + ref.read(gymLogProvider.notifier).editLog(loggedEntry); + return; + } + final log = Log.fromSetConfigData( slotEntryPage.setConfigData!, routineId: state.routine.id, @@ -365,15 +374,8 @@ class GymStateNotifier extends _$GymStateNotifier { _savePrefs(); } - void markSlotPageAsDone(String uuid, {required bool isDone}) { - final slotPage = state.getSlotPageByUUID(uuid); - if (slotPage == null) { - _logger.warning('No slot page found for UUID $uuid'); - return; - } - - final updatedSlotPage = slotPage.copyWith(logDone: isDone); - + /// Replaces the slot page with [uuid] by [updatedSlotPage] in the state. + void _updateSlotPage(String uuid, SlotPageEntry updatedSlotPage) { final updatedPages = state.pages.map((page) { if (page.type != PageType.set) { return page; @@ -390,6 +392,41 @@ class GymStateNotifier extends _$GymStateNotifier { }).toList(); state = state.copyWith(pages: updatedPages); + } + + /// Starts the timer of the slot page with [uuid], unless it is already + /// running. + /// + /// The start time lives in the gym state (not the timer widget) so the + /// countdown continues when the page is disposed and re-created while + /// navigating, e.g. when going back to correct a logged set during the + /// rest period. + void startTimerIfNeeded(String uuid) { + final slotPage = state.getSlotPageByUUID(uuid); + if (slotPage == null) { + _logger.warning('No slot page found for UUID $uuid'); + return; + } + if (slotPage.timerStartedAt != null) { + return; + } + + _updateSlotPage(uuid, slotPage.copyWith(timerStartedAt: clock.now())); + _logger.fine('Started timer for slot page UUID $uuid'); + } + + /// [log] is the entry saved from this page during the current workout. It + /// is kept on the slot page so revisiting the page pre-fills the form with + /// the saved values and saving updates the row instead of inserting a + /// duplicate. + void markSlotPageAsDone(String uuid, {required bool isDone, Log? log}) { + final slotPage = state.getSlotPageByUUID(uuid); + if (slotPage == null) { + _logger.warning('No slot page found for UUID $uuid'); + return; + } + + _updateSlotPage(uuid, slotPage.copyWith(logDone: isDone, loggedEntry: log)); _logger.fine('Set logDone=$isDone for slot page UUID $uuid'); } diff --git a/lib/features/routines/widgets/gym_mode/log_page.dart b/lib/features/routines/widgets/gym_mode/log_page.dart index 6226bee1f..3ee625c30 100644 --- a/lib/features/routines/widgets/gym_mode/log_page.dart +++ b/lib/features/routines/widgets/gym_mode/log_page.dart @@ -393,12 +393,24 @@ class _LogFormWidgetState extends ConsumerState { // A failed write is intentionally left to propagate to the global // error handler; the success path below is then skipped. - await logProvider.addEntry(log); + // + // Re-saving a page that was already logged in this workout + // updates the existing row instead of inserting a duplicate. + // The id/session can be missing on the form log, e.g. after + // copying values from a past log, so restore them first. + final existingEntry = page.loggedEntry; + if (existingEntry == null) { + await logProvider.addEntry(log); + } else { + log.id = existingEntry.id; + log.sessionId = existingEntry.sessionId; + await logProvider.updateEntry(log); + } if (!context.mounted) { return; } - gymProvider.markSlotPageAsDone(page.uuid, isDone: true); + gymProvider.markSlotPageAsDone(page.uuid, isDone: true, log: log); showSnackbar( context, i18n.successfullySaved, diff --git a/lib/features/routines/widgets/gym_mode/timer.dart b/lib/features/routines/widgets/gym_mode/timer.dart index e1b807606..76f77750a 100644 --- a/lib/features/routines/widgets/gym_mode/timer.dart +++ b/lib/features/routines/widgets/gym_mode/timer.dart @@ -17,6 +17,8 @@ */ import 'dart:async'; +import 'package:clock/clock.dart'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,7 +28,7 @@ import 'package:wger/features/routines/providers/gym_state_notifier.dart'; import 'package:wger/features/routines/widgets/gym_mode/navigation.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -class TimerWidget extends StatefulWidget { +class TimerWidget extends ConsumerStatefulWidget { final PageController _controller; /// Identifies which slot page this widget renders, so it shows its own @@ -40,15 +42,22 @@ class TimerWidget extends StatefulWidget { _TimerWidgetState createState() => _TimerWidgetState(); } -class _TimerWidgetState extends State { - late DateTime _startTime; +class _TimerWidgetState extends ConsumerState { final _maxSeconds = 600; late Timer _uiTimer; @override void initState() { super.initState(); - _startTime = DateTime.now(); + + // The start time lives in the gym state so the timer continues when the + // page is disposed and re-created while navigating. Deferred because a + // provider can't be modified during the widget life-cycle. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(gymStateProvider.notifier).startTimerIfNeeded(widget.slotUuid); + } + }); _uiTimer = Timer.periodic(const Duration(seconds: 1), (_) { // ignore: no-empty-block, avoid-empty-setstate @@ -66,7 +75,11 @@ class _TimerWidgetState extends State { @override Widget build(BuildContext context) { - final elapsed = DateTime.now().difference(_startTime).inSeconds; + final startTime = ref + .watch(gymStateProvider) + .getSlotPageByUUID(widget.slotUuid) + ?.timerStartedAt; + final elapsed = clock.now().difference(startTime ?? clock.now()).inSeconds; final displaySeconds = elapsed > _maxSeconds ? _maxSeconds : elapsed; final displayTime = DateTime(2000, 1, 1, 0, 0, 0).add(Duration(seconds: displaySeconds)); @@ -117,15 +130,36 @@ class TimerCountdownWidget extends ConsumerStatefulWidget { } class _TimerCountdownWidgetState extends ConsumerState { - late DateTime _endTime; late Timer _uiTimer; bool _hasNotified = false; + /// The moment the countdown ends. The start time lives in the gym state so + /// the countdown continues when the page is disposed and re-created while + /// navigating; until the state is updated, fall back to starting now. + DateTime _endTime(GymModeState gymState) { + final startedAt = gymState.getSlotPageByUUID(widget.slotUuid)?.timerStartedAt; + return (startedAt ?? clock.now()).add(Duration(seconds: widget._seconds)); + } + @override void initState() { super.initState(); - _endTime = DateTime.now().add(Duration(seconds: widget._seconds)); + + // Deferred because a provider can't be modified during the widget + // life-cycle. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + ref.read(gymStateProvider.notifier).startTimerIfNeeded(widget.slotUuid); + + // Don't notify when returning to a countdown that already expired while + // the user was on another page. + if (_endTime(ref.read(gymStateProvider)).isBefore(clock.now())) { + _hasNotified = true; + } + }); _uiTimer = Timer.periodic(const Duration(seconds: 1), (_) { // ignore: no-empty-block, avoid-empty-setstate @@ -143,10 +177,10 @@ class _TimerCountdownWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - final remaining = _endTime.difference(DateTime.now()); + final gymState = ref.watch(gymStateProvider); + final remaining = _endTime(gymState).difference(clock.now()); final remainingSeconds = remaining.inSeconds <= 0 ? 0 : remaining.inSeconds; final displayTime = DateTime(2000, 1, 1, 0, 0, 0).add(Duration(seconds: remainingSeconds)); - final gymState = ref.watch(gymStateProvider); // When countdown finishes, notify ONCE, and respect settings if (remainingSeconds == 0 && !_hasNotified) { diff --git a/test/features/routines/providers/gym_state_test.dart b/test/features/routines/providers/gym_state_test.dart index a0b95bf7d..5559ab652 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'; @@ -86,6 +88,80 @@ void main() { } } }); + + test('Stores the logged entry on the slot page', () { + // Arrange + final slotPage = notifier.state.pages[1].slotPages[1]; + expect(slotPage.type, SlotPageType.log); + final log = Log.fromSetConfigData(slotPage.setConfigData!, routineId: 1, iteration: 1) + ..id = 'log-1'; + + // Act + notifier.markSlotPageAsDone(slotPage.uuid, isDone: true, log: log); + + // Assert + expect(notifier.state.getSlotPageByUUID(slotPage.uuid)!.loggedEntry, same(log)); + }); + }); + + group('GymStateNotifier.setCurrentPage', () { + test('Seeds the gym log from the saved entry when re-visiting a logged page', () { + // Arrange + final slotPage = notifier.state.pages[1].slotPages[1]; + expect(slotPage.type, SlotPageType.log); + final log = Log.fromSetConfigData(slotPage.setConfigData!, routineId: 1, iteration: 1) + ..id = 'log-1' + ..sessionId = 'session-1'; + notifier.markSlotPageAsDone(slotPage.uuid, isDone: true, log: log); + + // Act + notifier.setCurrentPage(slotPage.pageIndex); + + // Assert: the id and session are kept so saving updates the row + // instead of inserting a duplicate + final gymLog = container.read(gymLogProvider); + expect(gymLog, same(log)); + expect(gymLog!.id, 'log-1'); + expect(gymLog.sessionId, 'session-1'); + }); + + test('Seeds a fresh template for a page without a saved entry', () { + // Act + notifier.setCurrentPage(2); + + // Assert + expect(container.read(gymLogProvider)!.id, isNull); + }); + }); + + group('GymStateNotifier.startTimerIfNeeded', () { + test('Sets the start time only once', () { + // Arrange + final timerPage = notifier.state.pages[1].slotPages[2]; + expect(timerPage.type, SlotPageType.timer); + + // Act + withClock(Clock.fixed(DateTime(2024, 5, 2, 12)), () { + notifier.startTimerIfNeeded(timerPage.uuid); + }); + + // Assert + expect( + notifier.state.getSlotPageByUUID(timerPage.uuid)!.timerStartedAt, + DateTime(2024, 5, 2, 12), + ); + + // Act: starting again does not restart the timer + withClock(Clock.fixed(DateTime(2024, 5, 2, 12, 5)), () { + notifier.startTimerIfNeeded(timerPage.uuid); + }); + + // Assert + expect( + notifier.state.getSlotPageByUUID(timerPage.uuid)!.timerStartedAt, + DateTime(2024, 5, 2, 12), + ); + }); }); group('GymStateNotifier.recalculateIndices', () { 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..6169e5aa9 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'; @@ -72,7 +73,11 @@ void main() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); testExercises = getTestExercises(); mockWorkoutLogRepo = MockWorkoutLogRepository(); - when(mockWorkoutLogRepo.addLocalDrift(any)).thenAnswer((_) async {}); + // Mimic the real insert: the freshly minted id is written back to the log + when(mockWorkoutLogRepo.addLocalDrift(any)).thenAnswer((invocation) async { + (invocation.positionalArguments[0] as Log).id = 'new-log-id'; + }); + when(mockWorkoutLogRepo.updateLocalDrift(any)).thenAnswer((_) async {}); // Past logs on the page come from this stream (per exercise); reuse the // test routine's logs so the previous-entries assertions keep working. when( @@ -244,6 +249,44 @@ void main() { expect(saved.iteration, gymState.iteration); }); + testWidgets('re-visiting a logged page restores the saved values and saving updates the row', ( + tester, + ) async { + seedLogPage(testdata.getTestRoutine()); + await pumpLogPage(tester); + + // Enter and save 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(); + verify(mockWorkoutLogRepo.addLocalDrift(captureAny)).called(1); + + // Navigate away (page 3 is the timer) and back (page 2 is the log page) + final gymNotifier = container.read(gymStateProvider.notifier); + gymNotifier.setCurrentPage(3); + gymNotifier.setCurrentPage(2); + await tester.pumpAndSettle(); + + // The form is seeded from the saved entry, keeping its id + expect(container.read(gymLogProvider)!.id, 'new-log-id'); + final repField = tester.widget(find.byType(EditableText).at(0)); + expect(repField.controller.text, '12'); + + // Saving again updates the existing row instead of inserting a duplicate + await tester.tap(find.byKey(const ValueKey('save-log-button'))); + await tester.pumpAndSettle(); + + final updated = + verify(mockWorkoutLogRepo.updateLocalDrift(captureAny)).captured.single as Log; + expect(updated.id, 'new-log-id'); + expect(updated.repetitions, 12); + expect(updated.weight, 34); + verifyNever(mockWorkoutLogRepo.addLocalDrift(any)); + }); + 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/timer_test.dart b/test/features/routines/widgets/gym_mode/timer_test.dart index 6e5cfef95..863a74975 100644 --- a/test/features/routines/widgets/gym_mode/timer_test.dart +++ b/test/features/routines/widgets/gym_mode/timer_test.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -100,5 +101,31 @@ void main() { expect(find.text('Up next'), findsNothing); }); + + testWidgets('countdown continues when the page is disposed and re-created', (tester) async { + final t0 = DateTime(2024, 5, 2, 12); + + await withClock(Clock.fixed(t0), () async { + await pumpTimer(tester, 3); + expect(find.text('1:30'), findsOneWidget); + }); + + // 30s later the countdown kept running (re-pumping forces a rebuild, + // in the app the periodic UI timer does this every second) + await withClock(Clock.fixed(t0.add(const Duration(seconds: 30))), () async { + await pumpTimer(tester, 3); + expect(find.text('1:00'), findsOneWidget); + }); + + // Leaving the page disposes the widget ... + await withClock(Clock.fixed(t0.add(const Duration(seconds: 45))), () async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + // ... and coming back must not restart the countdown + await pumpTimer(tester, 3); + expect(find.text('0:45'), findsOneWidget); + }); + }); }); }