From ffc2f1a67425877d533e22f62084ba42f450f191 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Tue, 2 Dec 2025 22:32:14 +0100 Subject: [PATCH] Allow some options for the user to control the timer between sets --- lib/l10n/app_en.arb | 19 +++ lib/providers/gym_state.dart | 103 ++++++++++++-- lib/widgets/routines/gym_mode/gym_mode.dart | 18 ++- .../routines/gym_mode/session_page.dart | 2 +- lib/widgets/routines/gym_mode/start_page.dart | 132 ++++++++++++++++-- lib/widgets/routines/gym_mode/timer.dart | 69 ++------- 6 files changed, 257 insertions(+), 86 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d49b3b1..96f2fb90 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -265,6 +265,12 @@ }, "gymModeShowExercises": "Show exercise overview pages", "gymModeShowTimer": "Show timer between sets", + "gymModeTimerType": "Timer type", + "gymModeTimerTypeHelText": "If a set has pause time, a countdown is always used.", + "countdown": "Countdown", + "stopwatch": "Stopwatch", + "gymModeDefaultCountdownTime": "Default countdown time, in seconds", + "gymModeNotifyOnCountdownFinish": "Notify on countdown end", "duration": "Duration", "durationHoursMinutes": "{hours}h {minutes}m", "@durationHoursMinutes": { @@ -664,6 +670,19 @@ } } }, + "formMinMaxValues": "Please enter a value between {min} and {max}", + "@formMinMaxValues": { + "description": "Error message when the user needs to enter a value between min and max", + "type": "text", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "enterMinCharacters": "Please enter at least {min} characters", "@enterMinCharacters": { "description": "Error message when the user hasn't entered the minimum amount characters in a form", diff --git a/lib/providers/gym_state.dart b/lib/providers/gym_state.dart index b42d7b42..5e5f7397 100644 --- a/lib/providers/gym_state.dart +++ b/lib/providers/gym_state.dart @@ -16,6 +16,14 @@ const DEFAULT_DURATION = Duration(hours: 5); const PREFS_SHOW_EXERCISES = 'showExercisePrefs'; const PREFS_SHOW_TIMER = 'showTimerPrefs'; +const PREFS_ALERT_COUNTDOWN = 'alertCountdownPrefs'; +const PREFS_USE_COUNTDOWN_BETWEEN_SETS = 'useCountdownBetweenSetsPrefs'; +const PREFS_COUNTDOWN_DURATION = 'countdownDurationSecondsPrefs'; + +/// In seconds +const DEFAULT_COUNTDOWN_DURATION = 180; +const MIN_COUNTDOWN_DURATION = 10; +const MAX_COUNTDOWN_DURATION = 1800; enum PageType { start, @@ -140,17 +148,23 @@ class SlotPageEntry { class GymModeState { final _logger = Logger('GymModeState'); + // Navigation data final bool isInitialized; final List pages; final int currentPage; - final bool showExercisePages; - final bool showTimerPages; - final TimeOfDay startTime; final DateTime validUntil; + // User settings + final bool showExercisePages; + final bool showTimerPages; + final bool alertOnCountdownEnd; + final bool useCountdownBetweenSets; + final Duration defaultCountdownDuration; + + // Routine data late final int dayId; late final int iteration; late final Routine routine; @@ -162,6 +176,9 @@ class GymModeState { this.showExercisePages = true, this.showTimerPages = true, + this.alertOnCountdownEnd = false, + this.useCountdownBetweenSets = false, + this.defaultCountdownDuration = const Duration(seconds: DEFAULT_COUNTDOWN_DURATION), int? dayId, int? iteration, @@ -185,28 +202,43 @@ class GymModeState { } GymModeState copyWith({ + // Navigation data bool? isInitialized, List? pages, - bool? showExercisePages, - bool? showTimerPages, int? currentPage, + + // Routine data int? dayId, int? iteration, DateTime? validUntil, TimeOfDay? startTime, Routine? routine, + + // User settings + bool? showExercisePages, + bool? showTimerPages, + bool? alertOnCountdownEnd, + bool? useCountdownBetweenSets, + int? defaultCountdownDuration, }) { return GymModeState( isInitialized: isInitialized ?? this.isInitialized, pages: pages ?? this.pages, - showExercisePages: showExercisePages ?? this.showExercisePages, - showTimerPages: showTimerPages ?? this.showTimerPages, currentPage: currentPage ?? this.currentPage, + dayId: dayId ?? this.dayId, iteration: iteration ?? this.iteration, validUntil: validUntil ?? this.validUntil, startTime: startTime ?? this.startTime, routine: routine ?? this.routine, + + showExercisePages: showExercisePages ?? this.showExercisePages, + showTimerPages: showTimerPages ?? this.showTimerPages, + alertOnCountdownEnd: alertOnCountdownEnd ?? this.alertOnCountdownEnd, + useCountdownBetweenSets: useCountdownBetweenSets ?? this.useCountdownBetweenSets, + defaultCountdownDuration: Duration( + seconds: defaultCountdownDuration ?? this.defaultCountdownDuration.inSeconds, + ), ); } @@ -303,15 +335,53 @@ class GymStateNotifier extends _$GymStateNotifier { if (showTimer != null && showTimer != state.showTimerPages) { state = state.copyWith(showTimerPages: showTimer); } - _logger.finer('Loaded preferences: showExercise=$showExercise showTimer=$showTimer'); + + final alertOnCountdownEnd = await prefs.getBool(PREFS_ALERT_COUNTDOWN); + if (alertOnCountdownEnd != null && alertOnCountdownEnd != state.alertOnCountdownEnd) { + state = state.copyWith(alertOnCountdownEnd: alertOnCountdownEnd); + } + + final useCountdownBetweenSets = await prefs.getBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS); + if (useCountdownBetweenSets != null && + useCountdownBetweenSets != state.useCountdownBetweenSets) { + state = state.copyWith(useCountdownBetweenSets: useCountdownBetweenSets); + } + + final defaultCountdownDurationSeconds = await prefs.getInt(PREFS_COUNTDOWN_DURATION); + if (defaultCountdownDurationSeconds != null && + defaultCountdownDurationSeconds != state.defaultCountdownDuration.inSeconds) { + state = state.copyWith( + defaultCountdownDuration: defaultCountdownDurationSeconds, + ); + } + + _logger.finer( + 'Loaded saved preferences: ' + 'showExercise=$showExercise ' + 'showTimer=$showTimer ' + 'alertOnCountdownEnd=$alertOnCountdownEnd ' + 'useCountdownBetweenSets=$useCountdownBetweenSets ' + 'defaultCountdownDurationSeconds=$defaultCountdownDurationSeconds', + ); } Future _savePrefs() async { final prefs = PreferenceHelper.asyncPref; await prefs.setBool(PREFS_SHOW_EXERCISES, state.showExercisePages); await prefs.setBool(PREFS_SHOW_TIMER, state.showTimerPages); + await prefs.setBool(PREFS_ALERT_COUNTDOWN, state.alertOnCountdownEnd); + await prefs.setBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS, state.useCountdownBetweenSets); + await prefs.setInt( + PREFS_COUNTDOWN_DURATION, + state.defaultCountdownDuration.inSeconds, + ); _logger.finer( - 'Saved preferences: showExercise=${state.showExercisePages} showTimer=${state.showTimerPages}', + 'Saved preferences: ' + 'showExercise=${state.showExercisePages} ' + 'showTimer=${state.showTimerPages} ' + 'alertOnCountdownEnd=${state.alertOnCountdownEnd} ' + 'useCountdownBetweenSets=${state.useCountdownBetweenSets} ' + 'defaultCountdownDuration=${state.defaultCountdownDuration.inSeconds}', ); } @@ -499,6 +569,21 @@ class GymStateNotifier extends _$GymStateNotifier { _savePrefs(); } + void setAlertOnCountdownEnd(bool value) { + state = state.copyWith(alertOnCountdownEnd: value); + _savePrefs(); + } + + void setUseCountdownBetweenSets(bool value) { + state = state.copyWith(useCountdownBetweenSets: value); + _savePrefs(); + } + + void setDefaultCountdownDuration(int duration) { + state = state.copyWith(defaultCountdownDuration: duration); + _savePrefs(); + } + void markSlotPageAsDone(String uuid, {required bool isDone}) { final slotPage = state.getSlotPageByUUID(uuid); if (slotPage == null) { diff --git a/lib/widgets/routines/gym_mode/gym_mode.dart b/lib/widgets/routines/gym_mode/gym_mode.dart index ec1ca64b..1113b23f 100644 --- a/lib/widgets/routines/gym_mode/gym_mode.dart +++ b/lib/widgets/routines/gym_mode/gym_mode.dart @@ -79,6 +79,7 @@ class _GymModeState extends ConsumerState { } List _getContent(GymModeState state) { + final gymState = ref.watch(gymStateProvider); final List out = []; // Workout overview @@ -95,12 +96,17 @@ class _GymModeState extends ConsumerState { out.add(LogPage(_controller)); } + // Timer. Use rest time from config data if available, otherwise use user settings + final rest = slotPage.setConfigData?.restTime; if (slotPage.type == SlotPageType.timer) { - if (slotPage.setConfigData!.restTime != null) { - out.add(TimerCountdownWidget(_controller, slotPage.setConfigData!.restTime!.toInt())); - } else { - out.add(TimerWidget(_controller)); - } + out.add( + (rest != null || gymState.useCountdownBetweenSets) + ? TimerCountdownWidget( + _controller, + (rest ?? gymState.defaultCountdownDuration.inSeconds).toInt(), + ) + : TimerWidget(_controller), + ); } } } @@ -144,7 +150,7 @@ class _GymModeState extends ConsumerState { // Check if the last page is reached if (page == children.length - 1) { widget._logger.finer('Last page reached, clearing gym state'); - // ref.read(gymStateProvider.notifier).clear(); + ref.read(gymStateProvider.notifier).clear(); } }, children: children, diff --git a/lib/widgets/routines/gym_mode/session_page.dart b/lib/widgets/routines/gym_mode/session_page.dart index 614dcf75..51830cb5 100644 --- a/lib/widgets/routines/gym_mode/session_page.dart +++ b/lib/widgets/routines/gym_mode/session_page.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 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 diff --git a/lib/widgets/routines/gym_mode/start_page.dart b/lib/widgets/routines/gym_mode/start_page.dart index 8e146ee8..62f5e545 100644 --- a/lib/widgets/routines/gym_mode/start_page.dart +++ b/lib/widgets/routines/gym_mode/start_page.dart @@ -34,6 +34,20 @@ class GymModeOptions extends ConsumerStatefulWidget { class _GymModeOptionsState extends ConsumerState { bool _showOptions = false; + late TextEditingController _countdownController; + + @override + void initState() { + super.initState(); + final initial = ref.read(gymStateProvider).defaultCountdownDuration.inSeconds.toString(); + _countdownController = TextEditingController(text: initial); + } + + @override + void dispose() { + _countdownController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -41,27 +55,117 @@ class _GymModeOptionsState extends ConsumerState { final gymNotifier = ref.watch(gymStateProvider.notifier); final i18n = AppLocalizations.of(context); + // If the value in the provider changed, update the controller text + final currentText = gymState.defaultCountdownDuration.inSeconds.toString(); + if (_countdownController.text != currentText) { + _countdownController.text = currentText; + } + return Column( children: [ AnimatedCrossFade( firstChild: const SizedBox.shrink(), secondChild: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Column( - children: [ - SwitchListTile( - key: const ValueKey('gym-mode-option-show-exercises'), - title: Text(i18n.gymModeShowExercises), - value: gymState.showExercisePages, - onChanged: (value) => gymNotifier.setShowExercisePages(value), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: Card( + child: SingleChildScrollView( + child: Column( + children: [ + SwitchListTile( + key: const ValueKey('gym-mode-option-show-exercises'), + title: Text(i18n.gymModeShowExercises), + value: gymState.showExercisePages, + onChanged: (value) => gymNotifier.setShowExercisePages(value), + ), + SwitchListTile( + key: const ValueKey('gym-mode-option-show-timer'), + title: Text(i18n.gymModeShowTimer), + value: gymState.showTimerPages, + onChanged: (value) => gymNotifier.setShowTimerPages(value), + ), + ListTile( + enabled: gymState.showTimerPages, + title: Text(i18n.gymModeTimerType), + trailing: DropdownButton( + key: const ValueKey('themeModeDropdown'), + value: gymState.useCountdownBetweenSets, + onChanged: gymState.showTimerPages + ? (bool? newValue) { + if (newValue != null) { + gymNotifier.setUseCountdownBetweenSets(newValue); + } + } + : null, + items: [false, true].map>((bool value) { + final label = value ? i18n.countdown : i18n.stopwatch; + + return DropdownMenuItem(value: value, child: Text(label)); + }).toList(), + ), + subtitle: Text(i18n.gymModeTimerTypeHelText), + ), + ListTile( + enabled: gymState.showTimerPages, + title: TextFormField( + controller: _countdownController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: i18n.gymModeDefaultCountdownTime, + suffix: IconButton( + onPressed: gymState.showTimerPages && gymState.useCountdownBetweenSets + ? () => gymNotifier.setDefaultCountdownDuration( + DEFAULT_COUNTDOWN_DURATION, + ) + : null, + icon: const Icon(Icons.refresh), + ), + ), + onChanged: (value) { + final intValue = int.tryParse(value); + if (intValue != null && + intValue > 0 && + intValue < MAX_COUNTDOWN_DURATION) { + gymNotifier.setDefaultCountdownDuration(intValue); + } + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) { + final intValue = int.tryParse(value!); + if (intValue == null || + intValue < MIN_COUNTDOWN_DURATION || + intValue > MAX_COUNTDOWN_DURATION) { + return i18n.formMinMaxValues( + MIN_COUNTDOWN_DURATION, + MAX_COUNTDOWN_DURATION, + ); + } + return null; + }, + enabled: gymState.showTimerPages && gymState.useCountdownBetweenSets, + ), + // trailing: IconButton( + // onPressed: gymState.showTimerPages && gymState.useCountdownBetweenSets + // ? () => gymNotifier.setDefaultCountdownDuration( + // DEFAULT_COUNTDOWN_DURATION, + // ) + // : null, + // icon: const Icon(Icons.refresh), + // ), + ), + SwitchListTile( + key: const ValueKey('gym-mode-notify-countdown'), + title: Text(i18n.gymModeNotifyOnCountdownFinish), + value: gymState.alertOnCountdownEnd, + onChanged: (gymState.showTimerPages && gymState.useCountdownBetweenSets) + ? (value) => gymNotifier.setAlertOnCountdownEnd(value) + : null, + ), + ], + ), ), - SwitchListTile( - key: const ValueKey('gym-mode-option-show-timer'), - title: Text(i18n.gymModeShowTimer), - value: gymState.showTimerPages, - onChanged: (value) => gymNotifier.setShowTimerPages(value), - ), - ], + ), ), ), crossFadeState: _showOptions ? CrossFadeState.showSecond : CrossFadeState.showFirst, diff --git a/lib/widgets/routines/gym_mode/timer.dart b/lib/widgets/routines/gym_mode/timer.dart index 3f8163e6..5661c03d 100644 --- a/lib/widgets/routines/gym_mode/timer.dart +++ b/lib/widgets/routines/gym_mode/timer.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 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 @@ -19,8 +19,10 @@ import 'dart:async'; 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/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; @@ -71,8 +73,7 @@ class _TimerWidgetState extends State { child: Center( child: Text( DateFormat('m:ss').format(displayTime), - style: - Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor), + style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor), ), ), ), @@ -82,7 +83,7 @@ class _TimerWidgetState extends State { } } -class TimerCountdownWidget extends StatefulWidget { +class TimerCountdownWidget extends ConsumerStatefulWidget { final PageController _controller; final int _seconds; @@ -95,13 +96,10 @@ class TimerCountdownWidget extends StatefulWidget { _TimerCountdownWidgetState createState() => _TimerCountdownWidgetState(); } -class _TimerCountdownWidgetState extends State { +class _TimerCountdownWidgetState extends ConsumerState { late DateTime _endTime; late Timer _uiTimer; - // NEW: settings + one-time notification flag - bool _soundEnabled = true; - bool _vibrationEnabled = true; bool _hasNotified = false; @override @@ -125,18 +123,18 @@ class _TimerCountdownWidgetState extends State { Widget build(BuildContext context) { final remaining = _endTime.difference(DateTime.now()); final remainingSeconds = remaining.inSeconds <= 0 ? 0 : remaining.inSeconds; - final displayTime = - DateTime(2000, 1, 1, 0, 0, 0).add(Duration(seconds: remainingSeconds)); + 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) { - if (_soundEnabled) { + if (gymState.alertOnCountdownEnd) { SystemSound.play(SystemSoundType.alert); - } - if (_vibrationEnabled) { HapticFeedback.mediumImpact(); } - _hasNotified = true; + setState(() { + _hasNotified = true; + }); } return Column( @@ -149,52 +147,11 @@ class _TimerCountdownWidgetState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // countdown time Text( DateFormat('m:ss').format(displayTime), - style: Theme.of(context) - .textTheme - .displayLarge! - .copyWith(color: wgerPrimaryColor), + style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor), ), const SizedBox(height: 16), - // NEW: simple settings row (Sound / Vibration) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: _soundEnabled, - onChanged: (value) { - setState(() { - _soundEnabled = value; - }); - }, - ), - const SizedBox(width: 4), - const Text('Sound'), - ], - ), - const SizedBox(width: 24), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: _vibrationEnabled, - onChanged: (value) { - setState(() { - _vibrationEnabled = value; - }); - }, - ), - const SizedBox(width: 4), - const Text('Vibration'), - ], - ), - ], - ), ], ), ),