diff --git a/lib/models/workouts/log.dart b/lib/models/workouts/log.dart index 18503fb29..291c41fcc 100644 --- a/lib/models/workouts/log.dart +++ b/lib/models/workouts/log.dart @@ -93,7 +93,9 @@ class Log { weight = setConfig.weight; weightTarget = setConfig.weight; - weightUnitId = setConfig.weightUnitId ?? WEIGHT_UNIT_KG; + // Fall back to the resolved unit object (set during routine hydration from + // the user's metric preference) when the config carries no explicit unit. + weightUnitId = setConfig.weightUnitId ?? setConfig.weightUnit?.id ?? WEIGHT_UNIT_KG; weightUnitObj = setConfig.weightUnit; repetitions = setConfig.repetitions; diff --git a/lib/models/workouts/set_config_data.dart b/lib/models/workouts/set_config_data.dart index 38b8e1520..47435945c 100644 --- a/lib/models/workouts/set_config_data.dart +++ b/lib/models/workouts/set_config_data.dart @@ -107,7 +107,7 @@ class SetConfigData { this.maxNrOfSets, this.weight, this.maxWeight, - this.weightUnitId = WEIGHT_UNIT_KG, + this.weightUnitId, this.weightRounding, this.repetitions, this.maxRepetitions, diff --git a/lib/models/workouts/set_config_data.g.dart b/lib/models/workouts/set_config_data.g.dart index e415005b7..eba87c76a 100644 --- a/lib/models/workouts/set_config_data.g.dart +++ b/lib/models/workouts/set_config_data.g.dart @@ -40,7 +40,7 @@ SetConfigData _$SetConfigDataFromJson(Map json) { maxNrOfSets: json['max_sets'] as num?, weight: stringToNumNull(json['weight'] as String?), maxWeight: stringToNumNull(json['max_weight'] as String?), - weightUnitId: (json['weight_unit'] as num?)?.toInt() ?? WEIGHT_UNIT_KG, + weightUnitId: (json['weight_unit'] as num?)?.toInt(), weightRounding: stringToNumNull(json['weight_rounding'] as String?), repetitions: stringToNumNull(json['repetitions'] as String?), maxRepetitions: stringToNumNull(json['max_repetitions'] as String?), diff --git a/lib/providers/routines_notifier.dart b/lib/providers/routines_notifier.dart index effe29130..bcaf133eb 100644 --- a/lib/providers/routines_notifier.dart +++ b/lib/providers/routines_notifier.dart @@ -19,6 +19,7 @@ import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wger/helpers/consts.dart'; import 'package:wger/models/workouts/day.dart'; import 'package:wger/models/workouts/day_data.dart'; import 'package:wger/models/workouts/repetition_unit.dart'; @@ -30,6 +31,7 @@ import 'package:wger/models/workouts/weight_unit.dart'; import 'package:wger/providers/exercises_notifier.dart'; import 'package:wger/providers/helpers.dart'; import 'package:wger/providers/routines_repository.dart'; +import 'package:wger/providers/user_profile_notifier.dart'; import 'package:wger/providers/workout_session_notifier.dart'; part 'routines_notifier.g.dart'; @@ -111,6 +113,9 @@ class RoutinesRiverpod extends _$RoutinesRiverpod { ref.listen(exercisesProvider, (_, _) => _rehydrate()); ref.listen(routineRepetitionUnitProvider, (_, _) => _rehydrate()); ref.listen(routineWeightUnitProvider, (_, _) => _rehydrate()); + // The default weight unit for set configs without an explicit one depends + // on the user's metric preference, so re-hydrate when the profile arrives. + ref.listen(userProfileProvider, (_, _) => _rehydrate()); return repo.watchAllDrift().map((freshRoutines) { final existing = state.value?.routines ?? const []; @@ -140,6 +145,13 @@ class RoutinesRiverpod extends _$RoutinesRiverpod { final repetitionUnits = ref.read(routineRepetitionUnitProvider).value ?? const []; final weightUnits = ref.read(routineWeightUnitProvider).value ?? const []; + final profile = ref.read(userProfileProvider).value; + + // Used for set configs that don't carry an explicit weight unit: kg for + // metric users, lb otherwise. Defaults to kg while the profile is still + // syncing (the profile listener re-hydrates once it arrives). + final defaultWeightUnitId = (profile?.isMetric ?? true) ? WEIGHT_UNIT_KG : WEIGHT_UNIT_LB; + final defaultWeightUnit = weightUnits.firstWhereOrNull((u) => u.id == defaultWeightUnitId); routine.sessions = sessions.where((s) => s.routineId == routine.id).toList(); @@ -195,9 +207,9 @@ class RoutinesRiverpod extends _$RoutinesRiverpod { setConfig.repetitionsUnit = repetitionUnits.firstWhereOrNull( (u) => u.id == setConfig.repetitionsUnitId, ); - setConfig.weightUnit = weightUnits.firstWhereOrNull( - (u) => u.id == setConfig.weightUnitId, - ); + setConfig.weightUnit = setConfig.weightUnitId != null + ? weightUnits.firstWhereOrNull((u) => u.id == setConfig.weightUnitId) + : defaultWeightUnit; } } } diff --git a/lib/providers/routines_notifier.g.dart b/lib/providers/routines_notifier.g.dart index 7306e7372..d59dca64e 100644 --- a/lib/providers/routines_notifier.g.dart +++ b/lib/providers/routines_notifier.g.dart @@ -111,7 +111,7 @@ final class RoutinesRiverpodProvider RoutinesRiverpod create() => RoutinesRiverpod(); } -String _$routinesRiverpodHash() => r'9d6909b714bf367f2976a3ed68dd74ba7b4b7e89'; +String _$routinesRiverpodHash() => r'40ef025119c8a6ad5c13ae5e5d29bf0f6b660780'; abstract class _$RoutinesRiverpod extends $StreamNotifier { Stream build(); diff --git a/test/routine/helpers/routine_form_test_overrides.dart b/test/routine/helpers/routine_form_test_overrides.dart index 1d5c518da..5d9cf6147 100644 --- a/test/routine/helpers/routine_form_test_overrides.dart +++ b/test/routine/helpers/routine_form_test_overrides.dart @@ -26,14 +26,15 @@ import 'package:wger/models/workouts/weight_unit.dart'; import 'package:wger/providers/exercise_repository.dart'; import 'package:wger/providers/exercises_notifier.dart'; import 'package:wger/providers/routines_notifier.dart'; +import 'package:wger/providers/user_profile_repository.dart'; import 'package:wger/providers/workout_session_repository.dart'; import 'routine_form_test_overrides.mocks.dart'; export 'routine_form_test_overrides.mocks.dart' - show MockExerciseRepository, MockWorkoutSessionRepository; + show MockExerciseRepository, MockWorkoutSessionRepository, MockUserProfileRepository; -@GenerateMocks([ExerciseRepository, WorkoutSessionRepository]) +@GenerateMocks([ExerciseRepository, WorkoutSessionRepository, UserProfileRepository]) /// Repository overrides for the two reference-data notifiers that /// `RoutinesRiverpod.fetchAndSetRoutineFull` `awaitFirstValue`s on: /// `exercisesProvider` and `workoutSessionProvider`. Both notifiers read @@ -46,15 +47,27 @@ export 'routine_form_test_overrides.mocks.dart' List exerciseAndSessionRepoOverrides({ MockExerciseRepository? exercise, MockWorkoutSessionRepository? session, + MockUserProfileRepository? userProfile, }) { final exerciseRepo = exercise ?? _emptyExerciseRepoMock(); final sessionRepo = session ?? _emptySessionRepoMock(); return [ exerciseRepositoryProvider.overrideWithValue(exerciseRepo), workoutSessionRepositoryProvider.overrideWithValue(sessionRepo), + // RoutinesRiverpod.build listens to the user profile to pick the default + // weight unit; stub it so the real Drift-backed repo isn't pulled in. + // Defaults to a null (metric) profile; pass [userProfile] to exercise the + // imperial default-unit path. + userProfileRepositoryProvider.overrideWithValue(userProfile ?? _emptyUserProfileRepoMock()), ]; } +MockUserProfileRepository _emptyUserProfileRepoMock() { + final mock = MockUserProfileRepository(); + when(mock.watchDrift()).thenAnswer((_) => Stream.value(null)); + return mock; +} + MockExerciseRepository _emptyExerciseRepoMock() { final mock = MockExerciseRepository(); when( @@ -80,10 +93,15 @@ MockWorkoutSessionRepository _emptySessionRepoMock() { List routineFormAmbientOverrides({ MockExerciseRepository? exercise, MockWorkoutSessionRepository? session, + MockUserProfileRepository? userProfile, List repetitionUnits = const [], List weightUnits = const [], }) => [ - ...exerciseAndSessionRepoOverrides(exercise: exercise, session: session), + ...exerciseAndSessionRepoOverrides( + exercise: exercise, + session: session, + userProfile: userProfile, + ), routineRepetitionUnitProvider.overrideWith( (ref) => Stream>.value(repetitionUnits), ), diff --git a/test/routine/helpers/routine_form_test_overrides.mocks.dart b/test/routine/helpers/routine_form_test_overrides.mocks.dart index 66755c555..a6630a98f 100644 --- a/test/routine/helpers/routine_form_test_overrides.mocks.dart +++ b/test/routine/helpers/routine_form_test_overrides.mocks.dart @@ -12,9 +12,11 @@ import 'package:wger/models/exercises/category.dart' as _i6; import 'package:wger/models/exercises/equipment.dart' as _i8; import 'package:wger/models/exercises/exercise_filters.dart' as _i5; import 'package:wger/models/exercises/muscle.dart' as _i9; +import 'package:wger/models/user/user_profile.dart' as _i14; import 'package:wger/models/workouts/session.dart' as _i12; import 'package:wger/providers/exercise_repository.dart' as _i2; import 'package:wger/providers/exercises_notifier.dart' as _i7; +import 'package:wger/providers/user_profile_repository.dart' as _i13; import 'package:wger/providers/workout_session_repository.dart' as _i11; // ignore_for_file: type=lint @@ -163,3 +165,29 @@ class MockWorkoutSessionRepository extends _i1.Mock implements _i11.WorkoutSessi ) as _i3.Future); } + +/// A class which mocks [UserProfileRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserProfileRepository extends _i1.Mock implements _i13.UserProfileRepository { + MockUserProfileRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Stream<_i14.UserProfile?> watchDrift() => + (super.noSuchMethod( + Invocation.method(#watchDrift, []), + returnValue: _i3.Stream<_i14.UserProfile?>.empty(), + ) + as _i3.Stream<_i14.UserProfile?>); + + @override + _i3.Future editLocalDrift(_i14.UserProfile? profile) => + (super.noSuchMethod( + Invocation.method(#editLocalDrift, [profile]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} diff --git a/test/routine/models/log_test.dart b/test/routine/models/log_test.dart index c979b6ae4..f2b72c946 100644 --- a/test/routine/models/log_test.dart +++ b/test/routine/models/log_test.dart @@ -1,6 +1,6 @@ /* * 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 @@ -22,6 +22,7 @@ import 'package:wger/models/exercises/category.dart'; import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/set_config_data.dart'; +import 'package:wger/models/workouts/weight_unit.dart'; void main() { group('Log.volume', () { @@ -178,6 +179,24 @@ void main() { expect(log.repetitionsUnitId, REP_UNIT_TILL_FAILURE_ID); }); + test('derives the unit ID from the resolved unit object when no explicit ID is set', () { + // Imperial users: routine hydration leaves weightUnitId null and only + // resolves the weightUnit object (lb) from the profile. The log must pick + // up lb for *both* the ID and the object + const lb = WeightUnit(id: WEIGHT_UNIT_LB, name: 'lb'); + final setConfig = SetConfigData( + exerciseId: 1, + slotEntryId: 1, + exercise: exercise, + weightUnit: lb, + ); + + final log = Log.fromSetConfigData(setConfig); + + expect(log.weightUnitId, WEIGHT_UNIT_LB); + expect(log.weightUnitObj, lb); + }); + test('copies weight and repetitions values', () { final setConfig = SetConfigData( exerciseId: 1, diff --git a/test/routine/routine_form_test.dart b/test/routine/routine_form_test.dart index e7029be6c..516d52af9 100644 --- a/test/routine/routine_form_test.dart +++ b/test/routine/routine_form_test.dart @@ -36,6 +36,7 @@ import 'package:wger/providers/exercises_notifier.dart'; import 'package:wger/providers/network_provider.dart'; import 'package:wger/providers/routines_notifier.dart'; import 'package:wger/providers/routines_repository.dart'; +import 'package:wger/providers/user_profile_repository.dart'; import 'package:wger/providers/workout_session_repository.dart'; import 'package:wger/screens/routine_edit_screen.dart'; import 'package:wger/screens/routine_screen.dart'; @@ -46,13 +47,19 @@ import '../../test_data/routines.dart'; import '../fake_connectivity.dart'; import './routine_form_test.mocks.dart'; -@GenerateMocks([RoutinesRepository, WorkoutSessionRepository, ExerciseRepository]) +@GenerateMocks([ + RoutinesRepository, + WorkoutSessionRepository, + ExerciseRepository, + UserProfileRepository, +]) void main() { installFakeConnectivity(); late MockRoutinesRepository mockRoutinesRepository; late MockWorkoutSessionRepository mockSessionRepo; late MockExerciseRepository mockExerciseRepo; + late MockUserProfileRepository mockUserProfileRepo; late StreamController> routineStream; late Routine existingRoutine; late Routine newRoutine; @@ -92,6 +99,10 @@ void main() { when( mockExerciseRepo.watchAllDrift(), ).thenAnswer((_) => Stream.value(const ExerciseState([]))); + // RoutinesRiverpod.build() also listens to the user profile for the default + // weight unit; stub it so the real PowerSync DB isn't pulled in. + mockUserProfileRepo = MockUserProfileRepository(); + when(mockUserProfileRepo.watchDrift()).thenAnswer((_) => Stream.value(null)); }); tearDown(() { @@ -106,6 +117,7 @@ void main() { routinesRepositoryProvider.overrideWithValue(mockRoutinesRepository), workoutSessionRepositoryProvider.overrideWithValue(mockSessionRepo), exerciseRepositoryProvider.overrideWithValue(mockExerciseRepo), + userProfileRepositoryProvider.overrideWithValue(mockUserProfileRepo), networkStatusProvider.overrideWithValue(isOnline), routineRepetitionUnitProvider.overrideWith( (ref) => Stream>.value(testRepetitionUnits), diff --git a/test/routine/routine_form_test.mocks.dart b/test/routine/routine_form_test.mocks.dart index e4c05df24..6130e7f5b 100644 --- a/test/routine/routine_form_test.mocks.dart +++ b/test/routine/routine_form_test.mocks.dart @@ -12,6 +12,7 @@ import 'package:wger/models/exercises/category.dart' as _i16; import 'package:wger/models/exercises/equipment.dart' as _i18; import 'package:wger/models/exercises/exercise_filters.dart' as _i15; import 'package:wger/models/exercises/muscle.dart' as _i19; +import 'package:wger/models/user/user_profile.dart' as _i22; import 'package:wger/models/workouts/base_config.dart' as _i6; import 'package:wger/models/workouts/day.dart' as _i3; import 'package:wger/models/workouts/repetition_unit.dart' as _i10; @@ -23,6 +24,7 @@ import 'package:wger/models/workouts/weight_unit.dart' as _i9; import 'package:wger/providers/exercise_repository.dart' as _i13; import 'package:wger/providers/exercises_notifier.dart' as _i17; import 'package:wger/providers/routines_repository.dart' as _i7; +import 'package:wger/providers/user_profile_repository.dart' as _i21; import 'package:wger/providers/workout_session_repository.dart' as _i11; // ignore_for_file: type=lint @@ -409,3 +411,29 @@ class MockExerciseRepository extends _i1.Mock implements _i13.ExerciseRepository ) as _i8.Stream>); } + +/// A class which mocks [UserProfileRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserProfileRepository extends _i1.Mock implements _i21.UserProfileRepository { + MockUserProfileRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i8.Stream<_i22.UserProfile?> watchDrift() => + (super.noSuchMethod( + Invocation.method(#watchDrift, []), + returnValue: _i8.Stream<_i22.UserProfile?>.empty(), + ) + as _i8.Stream<_i22.UserProfile?>); + + @override + _i8.Future editLocalDrift(_i22.UserProfile? profile) => + (super.noSuchMethod( + Invocation.method(#editLocalDrift, [profile]), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) + as _i8.Future); +} diff --git a/test/routine/routines_provider_test.dart b/test/routine/routines_provider_test.dart index 5a509bc8f..61f27d65e 100644 --- a/test/routine/routines_provider_test.dart +++ b/test/routine/routines_provider_test.dart @@ -22,15 +22,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:wger/models/exercises/exercise.dart'; +import 'package:wger/models/user/user_profile.dart'; import 'package:wger/models/workouts/day.dart'; +import 'package:wger/models/workouts/day_data.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/routine.dart'; import 'package:wger/models/workouts/session.dart'; +import 'package:wger/models/workouts/set_config_data.dart'; import 'package:wger/models/workouts/slot.dart'; +import 'package:wger/models/workouts/slot_data.dart'; import 'package:wger/models/workouts/slot_entry.dart'; import 'package:wger/providers/exercises_notifier.dart'; import 'package:wger/providers/routines_notifier.dart'; import 'package:wger/providers/routines_repository.dart'; +import 'package:wger/providers/user_profile_notifier.dart'; import '../../test_data/exercises.dart'; import '../../test_data/routines.dart'; @@ -359,6 +364,60 @@ void main() { // Assert: `_fullFetchInFlight` collapses them into a single roundtrip. verify(mockRepo.fetchAndSetRoutineFullServer(101)).called(1); }); + + test('resolves the default weight unit from an imperial profile', () async { + // A set config without an explicit weight unit must inherit the unit + // implied by the profile: lb (id 2) for an imperial user, not kg. + overrideStreams(mockExerciseRepo); + final setConfig = SetConfigData( + exerciseId: 1, + slotEntryId: 1, + exercise: getTestExercises()[0], + // No weightUnit / weightUnitId: the server omits it, the default applies. + ); + final routine = Routine(id: 101, name: 'Test routine') + ..dayDataGym = [ + DayData( + iteration: 1, + date: DateTime(2024, 11, 1), + label: '', + day: Day(id: 1, routineId: 101, name: 'Test', order: 1), + slots: [ + SlotData(comment: '', isSuperset: false, exerciseIds: [1], setConfigs: [setConfig]), + ], + ), + ]; + when(mockRepo.fetchAndSetRoutineFullServer(101)).thenAnswer((_) async => routine); + + final imperialProfileRepo = MockUserProfileRepository(); + when( + imperialProfileRepo.watchDrift(), + ).thenAnswer((_) => Stream.value(UserProfile(id: 1, weightUnitStr: 'lb'))); + + final container = ProviderContainer.test( + overrides: [ + routinesRepositoryProvider.overrideWithValue(mockRepo), + ...routineFormAmbientOverrides( + exercise: mockExerciseRepo, + session: mockSessionRepo, + userProfile: imperialProfileRepo, + repetitionUnits: testRepetitionUnits, + weightUnits: testWeightUnits, + ), + ], + ); + + // fetchAndSetRoutineFull doesn't await the profile, so keep it alive and + // let its stream emit before hydration reads it (post-sync steady state). + container.listen(userProfileProvider, (_, _) {}); + await pumpEventQueue(); + final notifier = container.read(routinesRiverpodProvider.notifier); + final result = await notifier.fetchAndSetRoutineFull(101); + + // testWeightUnit2 has id 2 == WEIGHT_UNIT_LB. + final hydratedConfig = result.dayDataGym[0].slots[0].setConfigs[0]; + expect(hydratedConfig.weightUnit, testWeightUnit2); + }); }); group('routineHydration family', () {