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 ffb5f9b27..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, ); } @@ -326,6 +343,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/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/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/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 efe21e26f..76f77750a 100644 --- a/lib/features/routines/widgets/gym_mode/timer.dart +++ b/lib/features/routines/widgets/gym_mode/timer.dart @@ -17,32 +17,47 @@ */ 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'; 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'; -class TimerWidget extends StatefulWidget { +class TimerWidget extends ConsumerStatefulWidget { 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(); } -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 @@ -60,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)); @@ -71,13 +90,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 +114,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 @@ -100,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 @@ -126,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) { @@ -161,6 +212,7 @@ class _TimerCountdownWidgetState extends ConsumerState { ).textTheme.displayLarge!.copyWith(color: Theme.of(context).colorScheme.primary), ), const SizedBox(height: 16), + UpNextWidget(widget.slotUuid), ], ), ), @@ -169,3 +221,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..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', () { @@ -625,4 +701,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/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 new file mode 100644 index 000000000..863a74975 --- /dev/null +++ b/test/features/routines/widgets/gym_mode/timer_test.dart @@ -0,0 +1,131 @@ +/* + * 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:clock/clock.dart'; +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); + }); + + 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); + }); + }); + }); +}