From 583982ba1db2623f7464fe7dd34966e2c284b802 Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:39:09 +0545 Subject: [PATCH 1/6] feat(gym-mode): add logScopeWeeks and showDistinctLogs to GymModeState - implement persistence for log scope and distinct settings - refactor(logs): update pastExerciseLogsProvider to support custom scope and deduplication - feat(ui): add log scope controls to StartPage and LogPage --- .../routines/providers/gym_state.dart | 12 +++- .../providers/gym_state_notifier.dart | 35 +++++++++- .../providers/workout_logs_notifier.dart | 30 ++++++++- .../providers/workout_logs_notifier.g.dart | 24 +++++-- .../providers/workout_logs_repository.dart | 43 ++++++------ .../routines/widgets/gym_mode/log_page.dart | 67 +++++++++++++++++++ .../routines/widgets/gym_mode/start_page.dart | 50 ++++++++++++++ lib/l10n/app_en.arb | 13 ++++ 8 files changed, 244 insertions(+), 30 deletions(-) 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..42576103b 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 != 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,16 @@ class GymStateNotifier extends _$GymStateNotifier { _savePrefs(); } + void setLogScopeWeeks(int? weeks) { + state = state.copyWith(logScopeWeeks: weeks); + _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/workout_logs_notifier.dart b/lib/features/routines/providers/workout_logs_notifier.dart index f627dee92..71a1fee9e 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'; @@ -33,10 +34,33 @@ 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); +} + +/// Returns the latest unique (reps, weight and weightUnit) per log +List _deduplicate(List logs) { + final seen = {}; + final result = []; + for (final log in logs) { + final key = '${log.repetitions}_${log.weight}_${log.weightUnitId}'; + if (seen.add(key)) { + result.add(log); + } + } + + return result; } 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..134e3dfa3 100644 --- a/lib/features/routines/providers/workout_logs_notifier.g.dart +++ b/lib/features/routines/providers/workout_logs_notifier.g.dart @@ -21,7 +21,7 @@ final class PastExerciseLogsProvider /// Streams the past logs for [exerciseId] within [routineId], newest first. 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 +47,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 +69,16 @@ final class PastExerciseLogsProvider } } -String _$pastExerciseLogsHash() => r'e2345fbf5c2bcbaa81a673d39212a59099de5424'; +String _$pastExerciseLogsHash() => r'b700cebfb109d1a8ff651d5af76812aabf2eae81'; /// Streams the past logs for [exerciseId] within [routineId], newest first. 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, @@ -86,8 +93,15 @@ final class PastExerciseLogsFamily extends $Family 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..6486ee149 100644 --- a/lib/features/routines/providers/workout_logs_repository.dart +++ b/lib/features/routines/providers/workout_logs_repository.dart @@ -44,29 +44,34 @@ class WorkoutLogRepository { /// Streams the logs for a single exercise within a routine, newest first, /// with their repetition and weight units attached. 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), - ]); + 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..82d820737 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), + + // logs settings + _LogScopeControls(gymState: gymState), + Expanded(child: _buildPastLogs(pastLogs)), Padding( @@ -404,3 +410,64 @@ class _LogFormWidgetState extends ConsumerState { ); } } + +/// Compact inline controls for overriding the global log-scope settings +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: [ + // ── Scope dropdown ────────────────────────────────────────── + 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)), + ), + ), + ], + ), + ], + ), + + // -- Distinct toggle -- + Row( + children: [ + Text(i18n.gymModeDistinctLogsShort, 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..63bc345c3 100644 --- a/lib/features/routines/widgets/gym_mode/start_page.dart +++ b/lib/features/routines/widgets/gym_mode/start_page.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:ffi'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/features/exercises/models/exercise.dart'; @@ -149,6 +151,10 @@ class _GymModeOptionsState extends ConsumerState { enabled: gymState.showTimerPages && gymState.useCountdownBetweenSets, ), ), + + // logs Controls + ..._buildLogHistorySettings(context, ref, i18n, gymState, gymNotifier), + SwitchListTile( key: const ValueKey('gym-mode-notify-countdown'), title: Text(i18n.gymModeNotifyOnCountdownFinish), @@ -253,3 +259,47 @@ class StartPage extends ConsumerWidget { ); } } + +// Helper method for the settings tiles used to modify the log scope and deduplication logic. +/// +/// These controls allow users to define the lookback duration and enable +/// set deduplication for the pastExerciseLogs stream. +List _buildLogHistorySettings( + BuildContext context, + WidgetRef ref, + AppLocalizations i18n, + GymModeState gymState, + GymStateNotifier gymNotifier, +) { + return [ + 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, + items: [ + DropdownMenuItem( + value: null, + child: Text(i18n.gymModeLogScopeCurrentRoutine), + ), + ...[8, 12, 25, 50].map( + (weeks) => DropdownMenuItem( + value: weeks, + child: Text(i18n.gymModeLogScopeWeeks(weeks)), + ), + ), + ], + onChanged: (value) => gymNotifier.setLogScopeWeeks(value), + ), + ), + SwitchListTile( + key: const ValueKey('gym-mode-distinct-logs'), + title: Text(i18n.gymModeDistinctLogs), + subtitle: Text(i18n.gymModeDistinctLogsHelp), + value: gymState.showDistinctLogs, + onChanged: (value) => gymNotifier.setShowDistinctLogs(value), + ), + ]; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91e0c8891..f46a1cf2d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -412,6 +412,19 @@ "@addSet": { "description": "Label for the button that adds a set (to a workout day)" }, + +"gymModeLogScope": "Last logs Scope", +"gymModeLogScopeHelp": "Which logs to show as reference while training", +"gymModeLogScopeCurrentRoutine": "Current routine", +"gymModeLogScopeWeeks": "Last {weeks} weeks", +"@gymModeLogScopeWeeks": { + "placeholders": { + "weeks": { "type": "int" } + } +}, +"gymModeDistinctLogs": "Show distinct logs only", +"gymModeDistinctLogsHelp": "Hide duplicate reps/weight combinations", +"gymModeDistinctLogsShort": "Distinct", "addMeal": "Add meal", "@addMeal": {}, "mealLogged": "Meal logged to diary", From 569c51c98a4c10631ff6904ecf86fdaee880fd0e Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Wed, 8 Jul 2026 17:00:41 +0200 Subject: [PATCH 2/6] Fix setLogScopeWeeks We now pass clearLogScopeWeeks so that selecting "current routine" actually resets the scope instead of keeping the previous week count --- .../providers/gym_state_notifier.dart | 5 +- .../providers/gym_state_notifier.g.dart | 2 +- .../routines/widgets/gym_mode/start_page.dart | 15 +--- test/core/validators_test.mocks.dart | 77 +++++++++++++++++++ .../routines/providers/gym_state_test.dart | 46 +++++++++++ .../routines/screens/gym_mode_test.mocks.dart | 4 +- .../routine_logs_screen_test.mocks.dart | 4 +- .../widgets/gym_mode/log_page_test.mocks.dart | 4 +- 8 files changed, 140 insertions(+), 17 deletions(-) diff --git a/lib/features/routines/providers/gym_state_notifier.dart b/lib/features/routines/providers/gym_state_notifier.dart index 42576103b..da720174a 100644 --- a/lib/features/routines/providers/gym_state_notifier.dart +++ b/lib/features/routines/providers/gym_state_notifier.dart @@ -73,7 +73,7 @@ class GymStateNotifier extends _$GymStateNotifier { } final logScopeWeeks = await prefs.getInt(PREFS_LOG_SCOPE_WEEKS); - if (logScopeWeeks != state.logScopeWeeks) { + if (logScopeWeeks != null && logScopeWeeks != state.logScopeWeeks) { state = state.copyWith(logScopeWeeks: logScopeWeeks); } @@ -333,8 +333,9 @@ class GymStateNotifier extends _$GymStateNotifier { _savePrefs(); } + /// Passing null resets the scope to the current routine void setLogScopeWeeks(int? weeks) { - state = state.copyWith(logScopeWeeks: weeks); + state = state.copyWith(logScopeWeeks: weeks, clearLogScopeWeeks: weeks == null); _savePrefs(); } 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/widgets/gym_mode/start_page.dart b/lib/features/routines/widgets/gym_mode/start_page.dart index 63bc345c3..a6ad69ba8 100644 --- a/lib/features/routines/widgets/gym_mode/start_page.dart +++ b/lib/features/routines/widgets/gym_mode/start_page.dart @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import 'dart:ffi'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/features/exercises/models/exercise.dart'; @@ -152,9 +150,6 @@ class _GymModeOptionsState extends ConsumerState { ), ), - // logs Controls - ..._buildLogHistorySettings(context, ref, i18n, gymState, gymNotifier), - SwitchListTile( key: const ValueKey('gym-mode-notify-countdown'), title: Text(i18n.gymModeNotifyOnCountdownFinish), @@ -163,6 +158,8 @@ class _GymModeOptionsState extends ConsumerState { ? (value) => gymNotifier.setAlertOnCountdownEnd(value) : null, ), + + ..._buildLogHistorySettings(i18n, gymState, gymNotifier), ], ), ), @@ -260,13 +257,9 @@ class StartPage extends ConsumerWidget { } } -// Helper method for the settings tiles used to modify the log scope and deduplication logic. -/// -/// These controls allow users to define the lookback duration and enable -/// set deduplication for the pastExerciseLogs stream. +/// Settings tiles controlling which past logs are shown while training: +/// the lookback duration and whether duplicate sets are collapsed. List _buildLogHistorySettings( - BuildContext context, - WidgetRef ref, AppLocalizations i18n, GymModeState gymState, GymStateNotifier gymNotifier, diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 7030eb892..0d8b0917c 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -1596,6 +1596,72 @@ 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 gymModeDistinctLogsHelp => + (super.noSuchMethod( + Invocation.getter(#gymModeDistinctLogsHelp), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeDistinctLogsHelp), + ), + ) + as String); + + @override + String get gymModeDistinctLogsShort => + (super.noSuchMethod( + Invocation.getter(#gymModeDistinctLogsShort), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#gymModeDistinctLogsShort), + ), + ) + as String); + @override String get addMeal => (super.noSuchMethod( @@ -4602,6 +4668,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/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(), ) From 275523f75b45b531a9e5b14fa57a7a6c09e03a9c Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Wed, 8 Jul 2026 17:49:38 +0200 Subject: [PATCH 3/6] Take the repetition unit ID into account while deduping --- .../providers/workout_logs_notifier.dart | 27 ++-- .../providers/workout_logs_notifier.g.dart | 35 ++++- .../providers/workout_logs_repository.dart | 13 +- .../routines/widgets/gym_mode/log_page.dart | 125 ++++++++------- .../providers/workout_logs_notifier_test.dart | 142 ++++++++++++++++++ .../workout_logs_notifier_test.mocks.dart | 77 ++++++++++ .../workout_logs_repository_test.dart | 65 ++++++++ 7 files changed, 402 insertions(+), 82 deletions(-) create mode 100644 test/features/routines/providers/workout_logs_notifier_test.dart create mode 100644 test/features/routines/providers/workout_logs_notifier_test.mocks.dart diff --git a/lib/features/routines/providers/workout_logs_notifier.dart b/lib/features/routines/providers/workout_logs_notifier.dart index 71a1fee9e..c24eb3be9 100644 --- a/lib/features/routines/providers/workout_logs_notifier.dart +++ b/lib/features/routines/providers/workout_logs_notifier.dart @@ -28,7 +28,12 @@ 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, { @@ -49,18 +54,18 @@ Stream> pastExerciseLogs( .map((logs) => distinct ? _deduplicate(logs) : logs); } -/// Returns the latest unique (reps, weight and weightUnit) per log +/// 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 = {}; - final result = []; - for (final log in logs) { - final key = '${log.repetitions}_${log.weight}_${log.weightUnitId}'; - if (seen.add(key)) { - result.add(log); - } - } + final seen = <(num?, int?, num?, int?)>{}; - return result; + 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 134e3dfa3..2877d601f 100644 --- a/lib/features/routines/providers/workout_logs_notifier.g.dart +++ b/lib/features/routines/providers/workout_logs_notifier.g.dart @@ -8,17 +8,32 @@ 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, int? weeksBack, bool distinct}) super.argument, @@ -71,7 +86,12 @@ final class PastExerciseLogsProvider 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 @@ -88,7 +108,12 @@ 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, diff --git a/lib/features/routines/providers/workout_logs_repository.dart b/lib/features/routines/providers/workout_logs_repository.dart index 6486ee149..454f3b489 100644 --- a/lib/features/routines/providers/workout_logs_repository.dart +++ b/lib/features/routines/providers/workout_logs_repository.dart @@ -41,14 +41,21 @@ 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({ int? routineId, required int exerciseId, DateTime? since, }) { - _logger.finer('Watching local logs for routine $routineId, exercise $exerciseId'); + _logger.finer( + 'Watching local logs for exercise $exerciseId, ' + 'routine ${routineId ?? 'any'}, since ${since ?? 'always'}', + ); final query = _db.select(_db.workoutLogTable).join([ leftOuterJoin( diff --git a/lib/features/routines/widgets/gym_mode/log_page.dart b/lib/features/routines/widgets/gym_mode/log_page.dart index 82d820737..91984e412 100644 --- a/lib/features/routines/widgets/gym_mode/log_page.dart +++ b/lib/features/routines/widgets/gym_mode/log_page.dart @@ -139,9 +139,9 @@ class LogPage extends ConsumerWidget { Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center), const SizedBox(height: 10), - // logs settings - _LogScopeControls(gymState: gymState), - + // 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( @@ -411,63 +411,62 @@ class _LogFormWidgetState extends ConsumerState { } } -/// Compact inline controls for overriding the global log-scope settings -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: [ - // ── Scope dropdown ────────────────────────────────────────── - 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)), - ), - ), - ], - ), - ], - ), - - // -- Distinct toggle -- - Row( - children: [ - Text(i18n.gymModeDistinctLogsShort, style: theme.textTheme.bodySmall), - Switch.adaptive( - value: gymState.showDistinctLogs, - onChanged: (v) => gymNotifier.setShowDistinctLogs(v), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ], - ), - ], - ), - ); - } -} +// 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.gymModeDistinctLogsShort, style: theme.textTheme.bodySmall), +// Switch.adaptive( +// value: gymState.showDistinctLogs, +// onChanged: (v) => gymNotifier.setShowDistinctLogs(v), +// materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, +// ), +// ], +// ), +// ], +// ), +// ); +// } +// } 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); From 79d663f17f63edd5d5708248bd1055516056ea81 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 9 Jul 2026 12:01:34 +0200 Subject: [PATCH 4/6] Move gym-mode settings directly into GymModeOptions I'd say the widget is not big enough to be split up (yet) --- .../routines/widgets/gym_mode/start_page.dart | 70 ++++++++----------- .../widgets/gym_mode/start_page_test.dart | 41 +++++++++++ 2 files changed, 70 insertions(+), 41 deletions(-) diff --git a/lib/features/routines/widgets/gym_mode/start_page.dart b/lib/features/routines/widgets/gym_mode/start_page.dart index a6ad69ba8..bcba57b1f 100644 --- a/lib/features/routines/widgets/gym_mode/start_page.dart +++ b/lib/features/routines/widgets/gym_mode/start_page.dart @@ -159,7 +159,35 @@ class _GymModeOptionsState extends ConsumerState { : null, ), - ..._buildLogHistorySettings(i18n, gymState, gymNotifier), + 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), + subtitle: Text(i18n.gymModeDistinctLogsHelp), + value: gymState.showDistinctLogs, + onChanged: (value) => gymNotifier.setShowDistinctLogs(value), + ), ], ), ), @@ -256,43 +284,3 @@ class StartPage extends ConsumerWidget { ); } } - -/// Settings tiles controlling which past logs are shown while training: -/// the lookback duration and whether duplicate sets are collapsed. -List _buildLogHistorySettings( - AppLocalizations i18n, - GymModeState gymState, - GymStateNotifier gymNotifier, -) { - return [ - 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, - items: [ - DropdownMenuItem( - value: null, - child: Text(i18n.gymModeLogScopeCurrentRoutine), - ), - ...[8, 12, 25, 50].map( - (weeks) => DropdownMenuItem( - value: weeks, - child: Text(i18n.gymModeLogScopeWeeks(weeks)), - ), - ), - ], - onChanged: (value) => gymNotifier.setLogScopeWeeks(value), - ), - ), - SwitchListTile( - key: const ValueKey('gym-mode-distinct-logs'), - title: Text(i18n.gymModeDistinctLogs), - subtitle: Text(i18n.gymModeDistinctLogsHelp), - value: gymState.showDistinctLogs, - onChanged: (value) => gymNotifier.setShowDistinctLogs(value), - ), - ]; -} 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); + }); } From 388b403b5d3bd267cef6d24b0150d2c474a4ea3f Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 9 Jul 2026 12:11:24 +0200 Subject: [PATCH 5/6] Cleanup some of the new translation keys --- .../routines/widgets/gym_mode/log_page.dart | 2 +- .../routines/widgets/gym_mode/start_page.dart | 8 ++++--- lib/l10n/app_de.arb | 7 +++++- lib/l10n/app_en.arb | 6 ++--- lib/l10n/app_es.arb | 7 +++++- lib/l10n/app_fr.arb | 7 +++++- test/core/validators_test.mocks.dart | 22 ------------------- 7 files changed, 26 insertions(+), 33 deletions(-) diff --git a/lib/features/routines/widgets/gym_mode/log_page.dart b/lib/features/routines/widgets/gym_mode/log_page.dart index 91984e412..2b69c93e6 100644 --- a/lib/features/routines/widgets/gym_mode/log_page.dart +++ b/lib/features/routines/widgets/gym_mode/log_page.dart @@ -457,7 +457,7 @@ class _LogFormWidgetState extends ConsumerState { // ), // Row( // children: [ -// Text(i18n.gymModeDistinctLogsShort, style: theme.textTheme.bodySmall), +// Text(i18n.gymModeDistinctLogs, style: theme.textTheme.bodySmall), // Switch.adaptive( // value: gymState.showDistinctLogs, // onChanged: (v) => gymNotifier.setShowDistinctLogs(v), diff --git a/lib/features/routines/widgets/gym_mode/start_page.dart b/lib/features/routines/widgets/gym_mode/start_page.dart index bcba57b1f..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, @@ -159,6 +161,7 @@ class _GymModeOptionsState extends ConsumerState { : null, ), + const Divider(), ListTile( key: const ValueKey('gym-mode-log-scope'), title: Text(i18n.gymModeLogScope), @@ -184,7 +187,6 @@ class _GymModeOptionsState extends ConsumerState { SwitchListTile( key: const ValueKey('gym-mode-distinct-logs'), title: Text(i18n.gymModeDistinctLogs), - subtitle: Text(i18n.gymModeDistinctLogsHelp), 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 f46a1cf2d..406c4985c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -413,7 +413,7 @@ "description": "Label for the button that adds a set (to a workout day)" }, -"gymModeLogScope": "Last logs Scope", +"gymModeLogScope": "Previous logs", "gymModeLogScopeHelp": "Which logs to show as reference while training", "gymModeLogScopeCurrentRoutine": "Current routine", "gymModeLogScopeWeeks": "Last {weeks} weeks", @@ -422,9 +422,7 @@ "weeks": { "type": "int" } } }, -"gymModeDistinctLogs": "Show distinct logs only", -"gymModeDistinctLogsHelp": "Hide duplicate reps/weight combinations", -"gymModeDistinctLogsShort": "Distinct", +"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 0d8b0917c..0c47b97bb 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -1640,28 +1640,6 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); - @override - String get gymModeDistinctLogsHelp => - (super.noSuchMethod( - Invocation.getter(#gymModeDistinctLogsHelp), - returnValue: _i3.dummyValue( - this, - Invocation.getter(#gymModeDistinctLogsHelp), - ), - ) - as String); - - @override - String get gymModeDistinctLogsShort => - (super.noSuchMethod( - Invocation.getter(#gymModeDistinctLogsShort), - returnValue: _i3.dummyValue( - this, - Invocation.getter(#gymModeDistinctLogsShort), - ), - ) - as String); - @override String get addMeal => (super.noSuchMethod( From 0a2ebed6f0faf86508b31c7197cdb9bc4d747df8 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 9 Jul 2026 12:20:42 +0200 Subject: [PATCH 6/6] Remove references to cocoapods, this is not needed anymore --- ios/Runner.xcodeproj/project.pbxproj | 8 -------- 1 file changed, 8 deletions(-) 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 */