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 */