diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 08bd47e13..8384323ad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -77,7 +77,6 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - BB0286322FD60C00014F981C /* Pods */, ); sourceTree = ""; }; @@ -105,13 +104,6 @@ path = Runner; sourceTree = ""; }; - BB0286322FD60C00014F981C /* Pods */ = { - isa = PBXGroup; - children = ( - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/lib/features/routines/providers/gym_state.dart b/lib/features/routines/providers/gym_state.dart index 3fb21dc77..9cfb61b69 100644 --- a/lib/features/routines/providers/gym_state.dart +++ b/lib/features/routines/providers/gym_state.dart @@ -31,6 +31,8 @@ const PREFS_SHOW_TIMER = 'showTimerPrefs'; const PREFS_ALERT_COUNTDOWN = 'alertCountdownPrefs'; const PREFS_USE_COUNTDOWN_BETWEEN_SETS = 'useCountdownBetweenSetsPrefs'; const PREFS_COUNTDOWN_DURATION = 'countdownDurationSecondsPrefs'; +const PREFS_LOG_SCOPE_WEEKS = 'logScopeWeeksPrefs'; +const PREFS_SHOW_DISTINCT_LOGS = 'showDistinctLogsPrefs'; /// In seconds const DEFAULT_COUNTDOWN_DURATION = 180; @@ -177,6 +179,8 @@ class GymModeState { final bool alertOnCountdownEnd; final bool useCountdownBetweenSets; final Duration countdownDuration; + final int? logScopeWeeks; + final bool showDistinctLogs; // Routine data late final int dayId; @@ -193,7 +197,8 @@ class GymModeState { this.alertOnCountdownEnd = true, this.useCountdownBetweenSets = false, this.countdownDuration = const Duration(seconds: DEFAULT_COUNTDOWN_DURATION), - + this.logScopeWeeks, + this.showDistinctLogs = true, int? dayId, int? iteration, Routine? routine, @@ -234,6 +239,9 @@ class GymModeState { bool? alertOnCountdownEnd, bool? useCountdownBetweenSets, int? countdownDuration, + int? logScopeWeeks, + bool clearLogScopeWeeks = false, + bool? showDistinctLogs, }) { return GymModeState( isInitialized: isInitialized ?? this.isInitialized, @@ -253,6 +261,8 @@ class GymModeState { countdownDuration: Duration( seconds: countdownDuration ?? this.countdownDuration.inSeconds, ), + logScopeWeeks: clearLogScopeWeeks ? null : (logScopeWeeks ?? this.logScopeWeeks), + showDistinctLogs: showDistinctLogs ?? this.showDistinctLogs, ); } diff --git a/lib/features/routines/providers/gym_state_notifier.dart b/lib/features/routines/providers/gym_state_notifier.dart index 09b9ef95d..da720174a 100644 --- a/lib/features/routines/providers/gym_state_notifier.dart +++ b/lib/features/routines/providers/gym_state_notifier.dart @@ -72,13 +72,25 @@ class GymStateNotifier extends _$GymStateNotifier { ); } + final logScopeWeeks = await prefs.getInt(PREFS_LOG_SCOPE_WEEKS); + if (logScopeWeeks != null && logScopeWeeks != state.logScopeWeeks) { + state = state.copyWith(logScopeWeeks: logScopeWeeks); + } + + final showDistinctLogs = await prefs.getBool(PREFS_SHOW_DISTINCT_LOGS); + if (showDistinctLogs != null && showDistinctLogs != state.showDistinctLogs) { + state = state.copyWith(showDistinctLogs: showDistinctLogs); + } + _logger.finer( 'Loaded saved preferences: ' 'showExercise=$showExercise ' 'showTimer=$showTimer ' 'alertOnCountdownEnd=$alertOnCountdownEnd ' 'useCountdownBetweenSets=$useCountdownBetweenSets ' - 'defaultCountdownDurationSeconds=$defaultCountdownDurationSeconds', + 'defaultCountdownDurationSeconds=$defaultCountdownDurationSeconds' + 'logScopeWeeks=$logScopeWeeks ' + 'showDistinctLogs=$showDistinctLogs ', ); } @@ -92,13 +104,22 @@ class GymStateNotifier extends _$GymStateNotifier { PREFS_COUNTDOWN_DURATION, state.countdownDuration.inSeconds, ); + if (state.logScopeWeeks != null) { + await prefs.setInt(PREFS_LOG_SCOPE_WEEKS, state.logScopeWeeks!); + } else { + await prefs.remove(PREFS_LOG_SCOPE_WEEKS); + } + await prefs.setBool(PREFS_SHOW_DISTINCT_LOGS, state.showDistinctLogs); + _logger.finer( 'Saved preferences: ' 'showExercise=${state.showExercisePages} ' 'showTimer=${state.showTimerPages} ' 'alertOnCountdownEnd=${state.alertOnCountdownEnd} ' 'useCountdownBetweenSets=${state.useCountdownBetweenSets} ' - 'defaultCountdownDuration=${state.countdownDuration.inSeconds}', + 'defaultCountdownDuration=${state.countdownDuration.inSeconds}' + 'logScopeWeeks=${state.logScopeWeeks} ' + 'showDistinctLogs=${state.showDistinctLogs} ', ); } @@ -312,6 +333,17 @@ class GymStateNotifier extends _$GymStateNotifier { _savePrefs(); } + /// Passing null resets the scope to the current routine + void setLogScopeWeeks(int? weeks) { + state = state.copyWith(logScopeWeeks: weeks, clearLogScopeWeeks: weeks == null); + _savePrefs(); + } + + void setShowDistinctLogs(bool value) { + state = state.copyWith(showDistinctLogs: value); + _savePrefs(); + } + void markSlotPageAsDone(String uuid, {required bool isDone}) { final slotPage = state.getSlotPageByUUID(uuid); if (slotPage == null) { diff --git a/lib/features/routines/providers/gym_state_notifier.g.dart b/lib/features/routines/providers/gym_state_notifier.g.dart index 04b694888..bfacd5425 100644 --- a/lib/features/routines/providers/gym_state_notifier.g.dart +++ b/lib/features/routines/providers/gym_state_notifier.g.dart @@ -40,7 +40,7 @@ final class GymStateNotifierProvider extends $NotifierProvider r'213315cfc2f3047fe99ab88e81ee7e570e7c6ec3'; +String _$gymStateNotifierHash() => r'cb812e35fc5aafcecb9b2cad0b220cce7b3190bf'; abstract class _$GymStateNotifier extends $Notifier { GymModeState build(); diff --git a/lib/features/routines/providers/workout_logs_notifier.dart b/lib/features/routines/providers/workout_logs_notifier.dart index f627dee92..c24eb3be9 100644 --- a/lib/features/routines/providers/workout_logs_notifier.dart +++ b/lib/features/routines/providers/workout_logs_notifier.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:clock/clock.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wger/features/routines/models/log.dart'; @@ -27,16 +28,44 @@ final workoutLogProvider = Provider((ref) { return WorkoutLogMutations(ref.read(workoutLogRepositoryProvider)); }); -/// Streams the past logs for [exerciseId] within [routineId], newest first. +/// Streams the past logs for [exerciseId], newest first. +/// +/// Without [weeksBack] only the logs of [routineId] are returned. Setting it +/// widens the scope to all routines and instead limits the logs to the last +/// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], +/// only the newest log of each repetition/weight combination is kept. @riverpod Stream> pastExerciseLogs( Ref ref, { required int routineId, required int exerciseId, + int? weeksBack, + bool distinct = true, }) { - return ref - .read(workoutLogRepositoryProvider) - .watchLogsByExerciseDrift(routineId: routineId, exerciseId: exerciseId); + final repo = ref.read(workoutLogRepositoryProvider); + final cutoff = weeksBack != null ? clock.now().subtract(Duration(days: weeksBack * 7)) : null; + + return repo + .watchLogsByExerciseDrift( + routineId: weeksBack == null ? routineId : null, + exerciseId: exerciseId, + since: cutoff, + ) + .map((logs) => distinct ? _deduplicate(logs) : logs); +} + +/// Keeps only the first log of each repetitions/weight combination, units included. +/// +/// Expects [logs] to be sorted newest first, so the newest log of each +/// combination survives. +List _deduplicate(List logs) { + final seen = <(num?, int?, num?, int?)>{}; + + return logs + .where( + (log) => seen.add((log.repetitions, log.repetitionsUnitId, log.weight, log.weightUnitId)), + ) + .toList(); } class WorkoutLogMutations { diff --git a/lib/features/routines/providers/workout_logs_notifier.g.dart b/lib/features/routines/providers/workout_logs_notifier.g.dart index 1751b1627..2877d601f 100644 --- a/lib/features/routines/providers/workout_logs_notifier.g.dart +++ b/lib/features/routines/providers/workout_logs_notifier.g.dart @@ -8,20 +8,35 @@ part of 'workout_logs_notifier.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -/// Streams the past logs for [exerciseId] within [routineId], newest first. +/// Streams the past logs for [exerciseId], newest first. +/// +/// Without [weeksBack] only the logs of [routineId] are returned. Setting it +/// widens the scope to all routines and instead limits the logs to the last +/// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], +/// only the newest log of each repetition/weight combination is kept. @ProviderFor(pastExerciseLogs) final pastExerciseLogsProvider = PastExerciseLogsFamily._(); -/// Streams the past logs for [exerciseId] within [routineId], newest first. +/// Streams the past logs for [exerciseId], newest first. +/// +/// Without [weeksBack] only the logs of [routineId] are returned. Setting it +/// widens the scope to all routines and instead limits the logs to the last +/// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], +/// only the newest log of each repetition/weight combination is kept. final class PastExerciseLogsProvider extends $FunctionalProvider>, List, Stream>> with $FutureModifier>, $StreamProvider> { - /// Streams the past logs for [exerciseId] within [routineId], newest first. + /// Streams the past logs for [exerciseId], newest first. + /// + /// Without [weeksBack] only the logs of [routineId] are returned. Setting it + /// widens the scope to all routines and instead limits the logs to the last + /// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], + /// only the newest log of each repetition/weight combination is kept. PastExerciseLogsProvider._({ required PastExerciseLogsFamily super.from, - required ({int routineId, int exerciseId}) super.argument, + required ({int routineId, int exerciseId, int? weeksBack, bool distinct}) super.argument, }) : super( retry: null, name: r'pastExerciseLogsProvider', @@ -47,11 +62,14 @@ final class PastExerciseLogsProvider @override Stream> create(Ref ref) { - final argument = this.argument as ({int routineId, int exerciseId}); + final argument = + this.argument as ({int routineId, int exerciseId, int? weeksBack, bool distinct}); return pastExerciseLogs( ref, routineId: argument.routineId, exerciseId: argument.exerciseId, + weeksBack: argument.weeksBack, + distinct: argument.distinct, ); } @@ -66,12 +84,21 @@ final class PastExerciseLogsProvider } } -String _$pastExerciseLogsHash() => r'e2345fbf5c2bcbaa81a673d39212a59099de5424'; +String _$pastExerciseLogsHash() => r'b700cebfb109d1a8ff651d5af76812aabf2eae81'; -/// Streams the past logs for [exerciseId] within [routineId], newest first. +/// Streams the past logs for [exerciseId], newest first. +/// +/// Without [weeksBack] only the logs of [routineId] are returned. Setting it +/// widens the scope to all routines and instead limits the logs to the last +/// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], +/// only the newest log of each repetition/weight combination is kept. final class PastExerciseLogsFamily extends $Family - with $FunctionalFamilyOverride>, ({int routineId, int exerciseId})> { + with + $FunctionalFamilyOverride< + Stream>, + ({int routineId, int exerciseId, int? weeksBack, bool distinct}) + > { PastExerciseLogsFamily._() : super( retry: null, @@ -81,13 +108,25 @@ final class PastExerciseLogsFamily extends $Family isAutoDispose: true, ); - /// Streams the past logs for [exerciseId] within [routineId], newest first. + /// Streams the past logs for [exerciseId], newest first. + /// + /// Without [weeksBack] only the logs of [routineId] are returned. Setting it + /// widens the scope to all routines and instead limits the logs to the last + /// [weeksBack] weeks, in which case [routineId] is ignored. With [distinct], + /// only the newest log of each repetition/weight combination is kept. PastExerciseLogsProvider call({ required int routineId, required int exerciseId, + int? weeksBack, + bool distinct = true, }) => PastExerciseLogsProvider._( - argument: (routineId: routineId, exerciseId: exerciseId), + argument: ( + routineId: routineId, + exerciseId: exerciseId, + weeksBack: weeksBack, + distinct: distinct, + ), from: this, ); diff --git a/lib/features/routines/providers/workout_logs_repository.dart b/lib/features/routines/providers/workout_logs_repository.dart index 7f6b3f5f8..454f3b489 100644 --- a/lib/features/routines/providers/workout_logs_repository.dart +++ b/lib/features/routines/providers/workout_logs_repository.dart @@ -41,32 +41,44 @@ class WorkoutLogRepository { WorkoutLogRepository(this._db); - /// Streams the logs for a single exercise within a routine, newest first, - /// with their repetition and weight units attached. + /// Streams the logs for a single exercise, newest first, with their + /// repetition and weight units attached. + /// + /// Only logs belonging to [routineId] are returned, pass null for all + /// routines. [since] additionally restricts the result to logs on or after + /// that point in time. Stream> watchLogsByExerciseDrift({ - required int routineId, + int? routineId, required int exerciseId, + DateTime? since, }) { - _logger.finer('Watching local logs for routine $routineId, exercise $exerciseId'); - - final query = - _db.select(_db.workoutLogTable).join([ - leftOuterJoin( - _db.routineRepetitionUnitTable, - _db.routineRepetitionUnitTable.id.equalsExp(_db.workoutLogTable.repetitionsUnitId), - ), - leftOuterJoin( - _db.routineWeightUnitTable, - _db.routineWeightUnitTable.id.equalsExp(_db.workoutLogTable.weightUnitId), - ), - ]) - ..where( - _db.workoutLogTable.routineId.equals(routineId) & - _db.workoutLogTable.exerciseId.equals(exerciseId), - ) - ..orderBy([ - OrderingTerm(expression: _db.workoutLogTable.date, mode: OrderingMode.desc), - ]); + _logger.finer( + 'Watching local logs for exercise $exerciseId, ' + 'routine ${routineId ?? 'any'}, since ${since ?? 'always'}', + ); + + final query = _db.select(_db.workoutLogTable).join([ + leftOuterJoin( + _db.routineRepetitionUnitTable, + _db.routineRepetitionUnitTable.id.equalsExp(_db.workoutLogTable.repetitionsUnitId), + ), + leftOuterJoin( + _db.routineWeightUnitTable, + _db.routineWeightUnitTable.id.equalsExp(_db.workoutLogTable.weightUnitId), + ), + ]); + + var whereCondition = _db.workoutLogTable.exerciseId.equals(exerciseId); + if (routineId != null) { + whereCondition &= _db.workoutLogTable.routineId.equals(routineId); + } + if (since != null) { + whereCondition &= _db.workoutLogTable.date.isBiggerOrEqualValue(since); + } + query.where(whereCondition); + query.orderBy([ + OrderingTerm(expression: _db.workoutLogTable.date, mode: OrderingMode.desc), + ]); return query.watch().map((rows) { return rows.map((row) { diff --git a/lib/features/routines/widgets/gym_mode/log_page.dart b/lib/features/routines/widgets/gym_mode/log_page.dart index d199c6cf5..2b69c93e6 100644 --- a/lib/features/routines/widgets/gym_mode/log_page.dart +++ b/lib/features/routines/widgets/gym_mode/log_page.dart @@ -79,6 +79,8 @@ class LogPage extends ConsumerWidget { pastExerciseLogsProvider( routineId: gymState.routine.id!, exerciseId: setConfigData.exerciseId, + weeksBack: gymState.logScopeWeeks, + distinct: gymState.showDistinctLogs, ), ); @@ -136,6 +138,10 @@ class LogPage extends ConsumerWidget { if (slotEntryPage.setConfigData!.comment.isNotEmpty) Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center), const SizedBox(height: 10), + + // Overriding the log scope from here is handled in a follow-up, the + // settings currently only live in the gym mode options. + // _LogScopeControls(gymState: gymState), Expanded(child: _buildPastLogs(pastLogs)), Padding( @@ -404,3 +410,63 @@ class _LogFormWidgetState extends ConsumerState { ); } } + +// Compact inline controls for overriding the global log-scope settings. Kept +// around for the follow-up that lets the scope be changed from the log page. +// +// class _LogScopeControls extends ConsumerWidget { +// final GymModeState gymState; +// +// const _LogScopeControls({required this.gymState}); +// +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final gymNotifier = ref.read(gymStateProvider.notifier); +// final i18n = AppLocalizations.of(context); +// final theme = Theme.of(context); +// +// return Padding( +// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), +// child: Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// Row( +// children: [ +// Icon(Icons.history, size: 18, color: theme.colorScheme.primary), +// const SizedBox(width: 4), +// DropdownButton( +// value: gymState.logScopeWeeks, +// isDense: true, +// underline: const SizedBox.shrink(), +// style: theme.textTheme.bodySmall, +// onChanged: (value) => gymNotifier.setLogScopeWeeks(value), +// items: [ +// DropdownMenuItem( +// value: null, +// child: Text(i18n.gymModeLogScopeCurrentRoutine), +// ), +// ...[8, 12, 25, 50].map( +// (w) => DropdownMenuItem( +// value: w, +// child: Text(i18n.gymModeLogScopeWeeks(w)), +// ), +// ), +// ], +// ), +// ], +// ), +// Row( +// children: [ +// Text(i18n.gymModeDistinctLogs, style: theme.textTheme.bodySmall), +// Switch.adaptive( +// value: gymState.showDistinctLogs, +// onChanged: (v) => gymNotifier.setShowDistinctLogs(v), +// materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, +// ), +// ], +// ), +// ], +// ), +// ); +// } +// } diff --git a/lib/features/routines/widgets/gym_mode/start_page.dart b/lib/features/routines/widgets/gym_mode/start_page.dart index bb1f5f9b0..0f43468cc 100644 --- a/lib/features/routines/widgets/gym_mode/start_page.dart +++ b/lib/features/routines/widgets/gym_mode/start_page.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2025 wger Team + * Copyright (c) 2020 - 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, + * This program 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. @@ -86,6 +86,8 @@ class _GymModeOptionsState extends ConsumerState { value: gymState.showTimerPages, onChanged: (value) => gymNotifier.setShowTimerPages(value), ), + + const Divider(), ListTile( key: const ValueKey('gym-mode-timer-type'), enabled: gymState.showTimerPages, @@ -149,6 +151,7 @@ class _GymModeOptionsState extends ConsumerState { enabled: gymState.showTimerPages && gymState.useCountdownBetweenSets, ), ), + SwitchListTile( key: const ValueKey('gym-mode-notify-countdown'), title: Text(i18n.gymModeNotifyOnCountdownFinish), @@ -157,6 +160,36 @@ class _GymModeOptionsState extends ConsumerState { ? (value) => gymNotifier.setAlertOnCountdownEnd(value) : null, ), + + const Divider(), + ListTile( + key: const ValueKey('gym-mode-log-scope'), + title: Text(i18n.gymModeLogScope), + subtitle: Text(i18n.gymModeLogScopeHelp), + trailing: DropdownButton( + key: const ValueKey('log-scope-dropdown'), + value: gymState.logScopeWeeks, + onChanged: (value) => gymNotifier.setLogScopeWeeks(value), + items: [ + DropdownMenuItem( + value: null, + child: Text(i18n.gymModeLogScopeCurrentRoutine), + ), + ...[8, 12, 25, 50].map( + (weeks) => DropdownMenuItem( + value: weeks, + child: Text(i18n.gymModeLogScopeWeeks(weeks)), + ), + ), + ], + ), + ), + SwitchListTile( + key: const ValueKey('gym-mode-distinct-logs'), + title: Text(i18n.gymModeDistinctLogs), + value: gymState.showDistinctLogs, + onChanged: (value) => gymNotifier.setShowDistinctLogs(value), + ), ], ), ), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 84bb6786b..44f79efc1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1151,5 +1151,10 @@ "sessionExpired": "Deine Sitzung ist abgelaufen, bitte melde dich erneut an", "endBeforeStart": "Der Endwert darf nicht vor dem Startwert liegen", "minLengthRoutine": "Die Routine muss mindestens {number} Wochen lang sein", - "maxLengthRoutine": "Die Routine darf maximal {number} Wochen lang sein" + "maxLengthRoutine": "Die Routine darf maximal {number} Wochen lang sein", + "gymModeLogScope": "Frühere Protokolle", + "gymModeLogScopeHelp": "Welche Protokolle beim Training als Referenz angezeigt werden", + "gymModeLogScopeCurrentRoutine": "Aktuelle Routine", + "gymModeLogScopeWeeks": "Letzte {weeks} Wochen", + "gymModeDistinctLogs": "Doppelte Werte ausblenden" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91e0c8891..406c4985c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -412,6 +412,17 @@ "@addSet": { "description": "Label for the button that adds a set (to a workout day)" }, + +"gymModeLogScope": "Previous logs", +"gymModeLogScopeHelp": "Which logs to show as reference while training", +"gymModeLogScopeCurrentRoutine": "Current routine", +"gymModeLogScopeWeeks": "Last {weeks} weeks", +"@gymModeLogScopeWeeks": { + "placeholders": { + "weeks": { "type": "int" } + } +}, +"gymModeDistinctLogs": "Hide duplicate values", "addMeal": "Add meal", "@addMeal": {}, "mealLogged": "Meal logged to diary", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f7063822d..af7501516 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1190,5 +1190,10 @@ "timeStartEndBothOrNeither": "Indica la hora de inicio y de fin, o deja ambas vacías", "endBeforeStart": "El final no puede ser anterior al inicio", "minLengthRoutine": "La rutina debe ser de al menos {number} semanas", - "maxLengthRoutine": "La rutina debe tener como mucho {number} semanas" + "maxLengthRoutine": "La rutina debe tener como mucho {number} semanas", + "gymModeLogScope": "Registros anteriores", + "gymModeLogScopeHelp": "Qué registros mostrar como referencia durante el entrenamiento", + "gymModeLogScopeCurrentRoutine": "Rutina actual", + "gymModeLogScopeWeeks": "Últimas {weeks} semanas", + "gymModeDistinctLogs": "Ocultar valores duplicados" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 13406e317..4ce492b02 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1161,5 +1161,10 @@ "timeStartEndBothOrNeither": "Indiquez l'heure de début et de fin, ou laissez les deux vides", "endBeforeStart": "La valeur de fin ne peut être avant le début", "minLengthRoutine": "La routine doit durer au moins {number} semaines", - "maxLengthRoutine": "La routine peut durer au maximum {number} semaines" + "maxLengthRoutine": "La routine peut durer au maximum {number} semaines", + "gymModeLogScope": "Journaux précédents", + "gymModeLogScopeHelp": "Quels journaux afficher comme référence pendant l'entraînement", + "gymModeLogScopeCurrentRoutine": "Routine actuelle", + "gymModeLogScopeWeeks": "{weeks} dernières semaines", + "gymModeDistinctLogs": "Masquer les valeurs en double" } diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 7030eb892..0c47b97bb 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -1596,6 +1596,50 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get gymModeLogScope => + (super.noSuchMethod( + Invocation.getter(#gymModeLogScope), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeLogScope), + ), + ) + as String); + + @override + String get gymModeLogScopeHelp => + (super.noSuchMethod( + Invocation.getter(#gymModeLogScopeHelp), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeLogScopeHelp), + ), + ) + as String); + + @override + String get gymModeLogScopeCurrentRoutine => + (super.noSuchMethod( + Invocation.getter(#gymModeLogScopeCurrentRoutine), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeLogScopeCurrentRoutine), + ), + ) + as String); + + @override + String get gymModeDistinctLogs => + (super.noSuchMethod( + Invocation.getter(#gymModeDistinctLogs), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeDistinctLogs), + ), + ) + as String); + @override String get addMeal => (super.noSuchMethod( @@ -4602,6 +4646,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String gymModeLogScopeWeeks(int? weeks) => + (super.noSuchMethod( + Invocation.method(#gymModeLogScopeWeeks, [weeks]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#gymModeLogScopeWeeks, [weeks]), + ), + ) + as String); + @override String planDateRange(String? startDate, String? endDate) => (super.noSuchMethod( diff --git a/test/features/routines/providers/gym_state_test.dart b/test/features/routines/providers/gym_state_test.dart index 33dae142b..5fba8fb04 100644 --- a/test/features/routines/providers/gym_state_test.dart +++ b/test/features/routines/providers/gym_state_test.dart @@ -18,6 +18,9 @@ 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/core/shared_preferences.dart'; import 'package:wger/features/routines/providers/gym_state.dart'; import 'package:wger/features/routines/providers/gym_state_notifier.dart'; @@ -29,6 +32,9 @@ void main() { late ProviderContainer container; setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + container = ProviderContainer.test(); notifier = container.read(gymStateProvider.notifier); notifier.state = notifier.state.copyWith( @@ -274,4 +280,44 @@ void main() { expect(notifier.state.totalPages, 11); }); }); + + group('GymStateNotifier.setLogScopeWeeks', () { + test('Sets the scope and persists it', () async { + // Act + notifier.setLogScopeWeeks(12); + await pumpEventQueue(); + + // Assert + expect(notifier.state.logScopeWeeks, 12); + expect(await PreferenceHelper.asyncPref.getInt(PREFS_LOG_SCOPE_WEEKS), 12); + }); + + test('Resets the scope to the current routine', () async { + // Arrange + notifier.setLogScopeWeeks(12); + await pumpEventQueue(); + + // Act + notifier.setLogScopeWeeks(null); + await pumpEventQueue(); + + // Assert + expect(notifier.state.logScopeWeeks, isNull); + expect(await PreferenceHelper.asyncPref.getInt(PREFS_LOG_SCOPE_WEEKS), isNull); + }); + }); + + group('GymModeState.copyWith', () { + test('Keeps the log scope when it is not passed', () { + final state = notifier.state.copyWith(logScopeWeeks: 8); + + expect(state.copyWith(showDistinctLogs: false).logScopeWeeks, 8); + }); + + test('Clears the log scope on clearLogScopeWeeks', () { + final state = notifier.state.copyWith(logScopeWeeks: 8); + + expect(state.copyWith(clearLogScopeWeeks: true).logScopeWeeks, isNull); + }); + }); } diff --git a/test/features/routines/providers/workout_logs_notifier_test.dart b/test/features/routines/providers/workout_logs_notifier_test.dart new file mode 100644 index 000000000..adc5ca92b --- /dev/null +++ b/test/features/routines/providers/workout_logs_notifier_test.dart @@ -0,0 +1,142 @@ +/* + * 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. + * + * This program 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_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:wger/features/routines/models/log.dart'; +import 'package:wger/features/routines/providers/workout_logs_notifier.dart'; +import 'package:wger/features/routines/providers/workout_logs_repository.dart'; + +import 'workout_logs_notifier_test.mocks.dart'; + +@GenerateMocks([WorkoutLogRepository]) +void main() { + late MockWorkoutLogRepository mockRepo; + late ProviderContainer container; + + setUp(() { + mockRepo = MockWorkoutLogRepository(); + container = ProviderContainer.test( + overrides: [workoutLogRepositoryProvider.overrideWithValue(mockRepo)], + ); + }); + + Log makeLog({ + num repetitions = 10, + int repetitionsUnitId = 1, + num weight = 50, + int weightUnitId = 1, + DateTime? date, + }) { + return Log( + exerciseId: 1, + routineId: 100, + repetitions: repetitions, + repetitionsUnitId: repetitionsUnitId, + weight: weight, + weightUnitId: weightUnitId, + date: date ?? DateTime.utc(2026, 4, 15), + ); + } + + void stubLogs(List logs) { + when( + mockRepo.watchLogsByExerciseDrift( + routineId: anyNamed('routineId'), + exerciseId: anyNamed('exerciseId'), + since: anyNamed('since'), + ), + ).thenAnswer((_) => Stream.value(logs)); + } + + Future> readLogs({int? weeksBack, bool distinct = true}) { + final provider = pastExerciseLogsProvider( + routineId: 100, + exerciseId: 1, + weeksBack: weeksBack, + distinct: distinct, + ); + // Keep the autoDispose provider alive until the stream emitted + container.listen(provider, (_, _) {}); + + return container.read(provider.future); + } + + group('pastExerciseLogs scope', () { + test('limits the logs to the routine when no scope is set', () async { + stubLogs([]); + + await readLogs(); + + verify( + mockRepo.watchLogsByExerciseDrift(routineId: 100, exerciseId: 1, since: null), + ).called(1); + }); + + test('drops the routine and cuts off at the given week when a scope is set', () async { + stubLogs([]); + final now = DateTime(2026, 4, 15, 12); + + await withClock(Clock.fixed(now), () => readLogs(weeksBack: 8)); + + verify( + mockRepo.watchLogsByExerciseDrift( + routineId: null, + exerciseId: 1, + since: now.subtract(const Duration(days: 56)), + ), + ).called(1); + }); + }); + + group('pastExerciseLogs deduplication', () { + test('keeps the newest log of each repetitions/weight combination', () async { + final newest = makeLog(date: DateTime.utc(2026, 4, 16)); + stubLogs([ + newest, + makeLog(date: DateTime.utc(2026, 4, 15)), + makeLog(weight: 60, date: DateTime.utc(2026, 4, 14)), + ]); + + final logs = await readLogs(); + + expect(logs, hasLength(2)); + expect(logs.first.date, newest.date); + expect(logs.map((l) => l.weight).toList(), [50, 60]); + }); + + test('keeps logs that only differ in their units', () async { + stubLogs([ + makeLog(), + makeLog(repetitionsUnitId: 2), + makeLog(weightUnitId: 2), + ]); + + expect(await readLogs(), hasLength(3)); + }); + + test('returns every log when disabled', () async { + stubLogs([makeLog(), makeLog(), makeLog()]); + + expect(await readLogs(distinct: false), hasLength(3)); + }); + }); +} diff --git a/test/features/routines/providers/workout_logs_notifier_test.mocks.dart b/test/features/routines/providers/workout_logs_notifier_test.mocks.dart new file mode 100644 index 000000000..76f3b37a8 --- /dev/null +++ b/test/features/routines/providers/workout_logs_notifier_test.mocks.dart @@ -0,0 +1,77 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/features/routines/providers/workout_logs_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/features/routines/models/log.dart' as _i4; +import 'package:wger/features/routines/providers/workout_logs_repository.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [WorkoutLogRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWorkoutLogRepository extends _i1.Mock implements _i2.WorkoutLogRepository { + MockWorkoutLogRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Stream> watchLogsByExerciseDrift({ + int? routineId, + required int? exerciseId, + DateTime? since, + }) => + (super.noSuchMethod( + Invocation.method(#watchLogsByExerciseDrift, [], { + #routineId: routineId, + #exerciseId: exerciseId, + #since: since, + }), + returnValue: _i3.Stream>.empty(), + ) + as _i3.Stream>); + + @override + _i3.Future deleteLocalDrift(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDrift, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future updateLocalDrift(_i4.Log? log) => + (super.noSuchMethod( + Invocation.method(#updateLocalDrift, [log]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDrift(_i4.Log? log) => + (super.noSuchMethod( + Invocation.method(#addLocalDrift, [log]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} diff --git a/test/features/routines/providers/workout_logs_repository_test.dart b/test/features/routines/providers/workout_logs_repository_test.dart index 67a403527..f388e3cd6 100644 --- a/test/features/routines/providers/workout_logs_repository_test.dart +++ b/test/features/routines/providers/workout_logs_repository_test.dart @@ -242,6 +242,71 @@ void main() { expect(logs.single.weightUnitObj?.name, 'kg'); }); + test('returns the logs of every routine when no routine is given', () async { + await seedUnits(); + await db + .into(db.workoutLogTable) + .insert(makeLog(exerciseId: 1, routineId: 100).toCompanion()); + await db + .into(db.workoutLogTable) + .insert(makeLog(exerciseId: 1, routineId: 999).toCompanion()); + await db + .into(db.workoutLogTable) + .insert(makeLog(exerciseId: 2, routineId: 100).toCompanion()); + + final logs = await repo.watchLogsByExerciseDrift(exerciseId: 1).first; + + expect(logs.map((l) => l.routineId).toSet(), {100, 999}); + }); + + test('only returns logs on or after the given date', () async { + await seedUnits(); + await db + .into(db.workoutLogTable) + .insert(makeLog(date: DateTime.utc(2026, 4, 14), weight: 14).toCompanion()); + await db + .into(db.workoutLogTable) + .insert(makeLog(date: DateTime.utc(2026, 4, 16), weight: 16).toCompanion()); + + final logs = await repo + .watchLogsByExerciseDrift( + routineId: 100, + exerciseId: 1, + since: DateTime.utc(2026, 4, 15), + ) + .first; + + expect(logs.map((l) => l.weight).toList(), [16]); + }); + + test('compares a cutoff given in local time as an instant', () async { + // Dates are stored as UTC text, a local cutoff is bound with its offset + // appended. Both sides go through JULIANDAY, so the comparison happens on + // the instant rather than on the raw string. + await seedUnits(); + await db + .into(db.workoutLogTable) + .insert(makeLog(date: DateTime.utc(2026, 4, 15, 9)).toCompanion()); + + final justBefore = await repo + .watchLogsByExerciseDrift( + routineId: 100, + exerciseId: 1, + since: DateTime.utc(2026, 4, 15, 8, 30).toLocal(), + ) + .first; + expect(justBefore, hasLength(1), reason: 'Cutoff half an hour before the log'); + + final justAfter = await repo + .watchLogsByExerciseDrift( + routineId: 100, + exerciseId: 1, + since: DateTime.utc(2026, 4, 15, 9, 30).toLocal(), + ) + .first; + expect(justAfter, isEmpty, reason: 'Cutoff half an hour after the log'); + }); + test('re-emits when a matching log is added', () async { await seedUnits(); final stream = repo.watchLogsByExerciseDrift(routineId: 100, exerciseId: 1); diff --git a/test/features/routines/screens/gym_mode_test.mocks.dart b/test/features/routines/screens/gym_mode_test.mocks.dart index e5b6377b8..ff6f689b0 100644 --- a/test/features/routines/screens/gym_mode_test.mocks.dart +++ b/test/features/routines/screens/gym_mode_test.mocks.dart @@ -535,13 +535,15 @@ class MockWorkoutLogRepository extends _i1.Mock implements _i27.WorkoutLogReposi @override _i10.Stream> watchLogsByExerciseDrift({ - required int? routineId, + int? routineId, required int? exerciseId, + DateTime? since, }) => (super.noSuchMethod( Invocation.method(#watchLogsByExerciseDrift, [], { #routineId: routineId, #exerciseId: exerciseId, + #since: since, }), returnValue: _i10.Stream>.empty(), ) diff --git a/test/features/routines/screens/routine_logs_screen_test.mocks.dart b/test/features/routines/screens/routine_logs_screen_test.mocks.dart index 1bb07e493..97fe8c788 100644 --- a/test/features/routines/screens/routine_logs_screen_test.mocks.dart +++ b/test/features/routines/screens/routine_logs_screen_test.mocks.dart @@ -138,13 +138,15 @@ class MockWorkoutLogRepository extends _i1.Mock implements _i9.WorkoutLogReposit @override _i5.Stream> watchLogsByExerciseDrift({ - required int? routineId, + int? routineId, required int? exerciseId, + DateTime? since, }) => (super.noSuchMethod( Invocation.method(#watchLogsByExerciseDrift, [], { #routineId: routineId, #exerciseId: exerciseId, + #since: since, }), returnValue: _i5.Stream>.empty(), ) diff --git a/test/features/routines/widgets/gym_mode/log_page_test.mocks.dart b/test/features/routines/widgets/gym_mode/log_page_test.mocks.dart index 03bdb5571..17a32b625 100644 --- a/test/features/routines/widgets/gym_mode/log_page_test.mocks.dart +++ b/test/features/routines/widgets/gym_mode/log_page_test.mocks.dart @@ -34,13 +34,15 @@ class MockWorkoutLogRepository extends _i1.Mock implements _i2.WorkoutLogReposit @override _i3.Stream> watchLogsByExerciseDrift({ - required int? routineId, + int? routineId, required int? exerciseId, + DateTime? since, }) => (super.noSuchMethod( Invocation.method(#watchLogsByExerciseDrift, [], { #routineId: routineId, #exerciseId: exerciseId, + #since: since, }), returnValue: _i3.Stream>.empty(), ) 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 b1c82ea25..4085b7906 100644 --- a/test/features/routines/widgets/gym_mode/start_page_test.dart +++ b/test/features/routines/widgets/gym_mode/start_page_test.dart @@ -169,4 +169,45 @@ void main() { expect(notifier.state.countdownDuration.inSeconds, DEFAULT_COUNTDOWN_DURATION); }); + + testWidgets('Log history options update notifier state', (tester) async { + await pumpGymModeOptions(tester); + await tester.tap(find.byKey(const ValueKey('gym-mode-options-tile'))); + await tester.pumpAndSettle(); + + final notifier = container.read(gymStateProvider.notifier); + expect(notifier.state.logScopeWeeks, isNull, reason: 'Defaults to the current routine'); + expect(notifier.state.showDistinctLogs, isTrue); + + // The tiles sit at the bottom of the scrollable options card + final scopeDropdown = find.byKey(const ValueKey('log-scope-dropdown')); + await tester.ensureVisible(scopeDropdown); + await tester.pumpAndSettle(); + + // Limit the scope to the last 12 weeks + await tester.tap(scopeDropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('Last 12 weeks').last); + await tester.pumpAndSettle(); + + expect(notifier.state.logScopeWeeks, 12); + + // And back to the current routine + await tester.ensureVisible(scopeDropdown); + await tester.pumpAndSettle(); + await tester.tap(scopeDropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('Current routine').last); + await tester.pumpAndSettle(); + + expect(notifier.state.logScopeWeeks, isNull); + + final distinctSwitch = find.byKey(const ValueKey('gym-mode-distinct-logs')); + await tester.ensureVisible(distinctSwitch); + await tester.pumpAndSettle(); + await tester.tap(distinctSwitch); + await tester.pump(); + + expect(notifier.state.showDistinctLogs, isFalse); + }); }