From a92897b2f9b8e97b69b841fa05925e29e73bc277 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 14:12:49 -0500 Subject: [PATCH 01/22] feat: add automatic weight sync from Apple Health and Google Health Connect Import body weight data from Apple Health (iOS) and Google Health Connect (Android) into wger when the app is opened. Uses the Flutter `health` package for cross-platform access. Feature is opt-in via a settings toggle. Key changes: - Add HealthSyncNotifier (Riverpod) for sync orchestration - Add HealthSyncSettingsTile in settings page - Add health package dependency and platform permissions - Fix WeightEntry.copyWith parameter type (int? -> num?) - Fix BodyWeightProvider.findByDate() to use calendar-date comparison - Raise Android minSdkVersion to 26 (Health Connect requirement) - Change MainActivity to extend FlutterFragmentActivity Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 24 ++ .../kotlin/de/wger/flutter/MainActivity.kt | 4 +- ios/Runner/Info.plist | 2 + ios/Runner/Runner.entitlements | 6 + lib/helpers/shared_preferences.dart | 26 ++ lib/models/body_weight/weight_entry.dart | 2 +- lib/models/body_weight/weight_entry.g.dart | 11 +- lib/providers/body_weight.dart | 7 +- lib/providers/health_sync.dart | 224 ++++++++++++++++++ lib/providers/health_sync.g.dart | 63 +++++ lib/screens/home_tabs_screen.dart | 10 + lib/widgets/core/settings.dart | 3 + lib/widgets/core/settings/health_sync.dart | 77 ++++++ pubspec.lock | 40 ++++ pubspec.yaml | 1 + test/core/settings_test.dart | 27 ++- test/weight/weight_model_test.dart | 14 ++ test/weight/weight_provider_test.dart | 17 ++ 19 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 lib/providers/health_sync.dart create mode 100644 lib/providers/health_sync.g.dart create mode 100644 lib/widgets/core/settings/health_sync.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f912e63b..0ffb8cfdd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -39,7 +39,7 @@ android { defaultConfig { // Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "de.wger.flutter" - minSdkVersion flutter.minSdkVersion + minSdkVersion 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4e50bad4a..2f8ef5362 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,11 +9,19 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt b/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt index 421496c68..3e9f4cd14 100644 --- a/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt @@ -1,6 +1,6 @@ package de.wger.flutter -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() { +class MainActivity: FlutterFragmentActivity() { } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9cec73d2b..aa101c62c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,8 @@ NSCameraUsageDescription Workout photos + NSHealthShareUsageDescription + wger uses your health data to automatically sync weight measurements from your smart scale UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2af..57cd45923 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,11 @@ aps-environment development + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + health-records + diff --git a/lib/helpers/shared_preferences.dart b/lib/helpers/shared_preferences.dart index cdab8a537..9501a53ae 100644 --- a/lib/helpers/shared_preferences.dart +++ b/lib/helpers/shared_preferences.dart @@ -68,4 +68,30 @@ class PreferenceHelper { ); } } + + // Health sync + static const _healthSyncEnabledKey = 'healthSyncEnabled'; + static const _lastHealthSyncTimestampKey = 'lastHealthSyncTimestamp'; + + Future setHealthSyncEnabled(bool value) async { + await PreferenceHelper.asyncPref.setBool(_healthSyncEnabledKey, value); + } + + Future getHealthSyncEnabled() async { + final value = await PreferenceHelper.asyncPref.getBool(_healthSyncEnabledKey); + return value ?? false; + } + + Future setLastHealthSyncTimestamp(String value) async { + await PreferenceHelper.asyncPref.setString(_lastHealthSyncTimestampKey, value); + } + + Future getLastHealthSyncTimestamp() async { + return PreferenceHelper.asyncPref.getString(_lastHealthSyncTimestampKey); + } + + Future clearHealthSyncPreferences() async { + await PreferenceHelper.asyncPref.remove(_healthSyncEnabledKey); + await PreferenceHelper.asyncPref.remove(_lastHealthSyncTimestampKey); + } } diff --git a/lib/models/body_weight/weight_entry.dart b/lib/models/body_weight/weight_entry.dart index 8daada31c..46601af05 100644 --- a/lib/models/body_weight/weight_entry.dart +++ b/lib/models/body_weight/weight_entry.dart @@ -40,7 +40,7 @@ class WeightEntry { } } - WeightEntry copyWith({int? id, int? weight, DateTime? date}) => WeightEntry( + WeightEntry copyWith({int? id, num? weight, DateTime? date}) => WeightEntry( id: id, weight: weight ?? this.weight, date: date ?? this.date, diff --git a/lib/models/body_weight/weight_entry.g.dart b/lib/models/body_weight/weight_entry.g.dart index 286c98661..fbaaa9c68 100644 --- a/lib/models/body_weight/weight_entry.g.dart +++ b/lib/models/body_weight/weight_entry.g.dart @@ -15,8 +15,9 @@ WeightEntry _$WeightEntryFromJson(Map json) { ); } -Map _$WeightEntryToJson(WeightEntry instance) => { - 'id': instance.id, - 'weight': numToString(instance.weight), - 'date': dateToUtcIso8601(instance.date), -}; +Map _$WeightEntryToJson(WeightEntry instance) => + { + 'id': instance.id, + 'weight': numToString(instance.weight), + 'date': dateToUtcIso8601(instance.date), + }; diff --git a/lib/providers/body_weight.dart b/lib/providers/body_weight.dart index cc93f7c35..8af2f4b3c 100644 --- a/lib/providers/body_weight.dart +++ b/lib/providers/body_weight.dart @@ -58,7 +58,12 @@ class BodyWeightProvider with ChangeNotifier { WeightEntry? findByDate(DateTime date) { try { - return _entries.firstWhere((plan) => plan.date == date); + return _entries.firstWhere( + (entry) => + entry.date.year == date.year && + entry.date.month == date.month && + entry.date.day == date.day, + ); } on StateError { return null; } diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart new file mode 100644 index 000000000..2bcfde48c --- /dev/null +++ b/lib/providers/health_sync.dart @@ -0,0 +1,224 @@ +/* + * 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 'dart:io'; + +import 'package:health/health.dart'; +import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wger/helpers/shared_preferences.dart'; +import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/providers/base_provider.dart'; +import 'package:wger/providers/wger_base_riverpod.dart'; + +part 'health_sync.g.dart'; + +class HealthSyncState { + final bool isEnabled; + final bool isSyncing; + final int lastSyncCount; + + const HealthSyncState({ + this.isEnabled = false, + this.isSyncing = false, + this.lastSyncCount = 0, + }); + + HealthSyncState copyWith({ + bool? isEnabled, + bool? isSyncing, + int? lastSyncCount, + }) { + return HealthSyncState( + isEnabled: isEnabled ?? this.isEnabled, + isSyncing: isSyncing ?? this.isSyncing, + lastSyncCount: lastSyncCount ?? this.lastSyncCount, + ); + } +} + +/// Initial sync lookback period +const int healthSyncInitialDays = 30; + +@Riverpod(keepAlive: true) +class HealthSyncNotifier extends _$HealthSyncNotifier { + final _logger = Logger('HealthSyncNotifier'); + late final Health _health; + late final WgerBaseProvider _baseProvider; + + static const _weightEntryUrl = 'weightentry'; + + @override + HealthSyncState build() { + _health = Health(); + _baseProvider = ref.read(wgerBaseProvider); + + // Load persisted sync preference on startup + _loadPersistedState(); + + return const HealthSyncState(); + } + + Future _loadPersistedState() async { + final enabled = await PreferenceHelper.instance.getHealthSyncEnabled(); + if (enabled) { + state = state.copyWith(isEnabled: true); + } + } + + /// Check if the health platform is available on this device + Future isAvailable() async { + if (Platform.isAndroid) { + await _health.configure(); + final status = await _health.getHealthConnectSdkStatus(); + return status == HealthConnectSdkStatus.sdkAvailable; + } + // iOS always has HealthKit available + return Platform.isIOS; + } + + /// Enable health sync: request permissions, save preference, trigger initial sync + Future enableSync() async { + _logger.info('Enabling health sync'); + + await _health.configure(); + + final authorized = await _health.requestAuthorization( + [HealthDataType.WEIGHT], + permissions: [HealthDataAccess.READ], + ); + + if (!authorized) { + _logger.warning('Health permissions not granted'); + return 0; + } + + await PreferenceHelper.instance.setHealthSyncEnabled(true); + state = state.copyWith(isEnabled: true); + + return syncOnAppOpen(); + } + + /// Disable health sync: clear preferences + Future disableSync() async { + _logger.info('Disabling health sync'); + await PreferenceHelper.instance.clearHealthSyncPreferences(); + state = const HealthSyncState(); + } + + /// Main sync method: read weight data from health platform, post new entries to backend + Future syncOnAppOpen({List? existingEntries}) async { + final prefs = PreferenceHelper.instance; + final enabled = await prefs.getHealthSyncEnabled(); + if (!enabled) { + return 0; + } + + if (state.isSyncing) { + return 0; + } + state = state.copyWith(isEnabled: true, isSyncing: true); + + try { + await _health.configure(); + + // Determine the start time for the query + final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); + final DateTime startTime; + if (lastSyncStr != null) { + startTime = DateTime.parse(lastSyncStr); + } else { + startTime = DateTime.now().subtract(const Duration(days: healthSyncInitialDays)); + } + final endTime = DateTime.now(); + + _logger.info('Syncing weight data from $startTime to $endTime'); + + // Read weight data from health platform + List dataPoints = await _health.getHealthDataFromTypes( + types: [HealthDataType.WEIGHT], + startTime: startTime, + endTime: endTime, + ); + dataPoints = _health.removeDuplicates(dataPoints); + + if (dataPoints.isEmpty) { + _logger.info('No new weight data from health platform'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + + _logger.info('Found ${dataPoints.length} weight data points'); + + int syncedCount = 0; + DateTime? latestSynced; + + for (final point in dataPoints) { + try { + final value = (point.value as NumericHealthValue).numericValue; + final weightKg = (value * 100).roundToDouble() / 100; // Round to 2 decimal places + final timestamp = point.dateFrom; + + // Fallback dedup: skip if an entry with the same timestamp exists locally + if (existingEntries != null) { + final duplicate = existingEntries.any( + (e) => + e.date.year == timestamp.year && + e.date.month == timestamp.month && + e.date.day == timestamp.day && + e.date.hour == timestamp.hour && + e.date.minute == timestamp.minute, + ); + if (duplicate) { + _logger.fine('Skipping duplicate entry for $timestamp'); + continue; + } + } + + // POST to backend with original timestamp + final entry = WeightEntry(weight: weightKg, date: timestamp); + await _baseProvider.post( + entry.toJson(), + _baseProvider.makeUrl(_weightEntryUrl), + ); + + syncedCount++; + if (latestSynced == null || timestamp.isAfter(latestSynced)) { + latestSynced = timestamp; + } + } catch (e) { + _logger.warning('Failed to sync weight entry: $e'); + // Best-effort: continue with next entry + } + } + + // Update last sync timestamp to the latest successfully synced reading + if (latestSynced != null) { + await prefs.setLastHealthSyncTimestamp(latestSynced.toIso8601String()); + } + + _logger.info('Synced $syncedCount weight entries'); + state = state.copyWith(isSyncing: false, lastSyncCount: syncedCount); + return syncedCount; + } catch (e) { + _logger.warning('Health sync failed: $e'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + } +} diff --git a/lib/providers/health_sync.g.dart b/lib/providers/health_sync.g.dart new file mode 100644 index 000000000..a10c6cec2 --- /dev/null +++ b/lib/providers/health_sync.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'health_sync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(HealthSyncNotifier) +final healthSyncProvider = HealthSyncNotifierProvider._(); + +final class HealthSyncNotifierProvider + extends $NotifierProvider { + HealthSyncNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'healthSyncProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$healthSyncNotifierHash(); + + @$internal + @override + HealthSyncNotifier create() => HealthSyncNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(HealthSyncState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$healthSyncNotifierHash() => + r'7a0929b0b1660729da0f0fc3a9a1a70af71cf894'; + +abstract class _$HealthSyncNotifier extends $Notifier { + HealthSyncState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + HealthSyncState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index 10de168f7..8371cd98c 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -28,6 +28,7 @@ import 'package:wger/providers/auth.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/exercises.dart'; import 'package:wger/providers/gallery.dart'; +import 'package:wger/providers/health_sync.dart'; import 'package:wger/providers/measurement.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/routines.dart'; @@ -147,6 +148,15 @@ class _HomeTabsScreenState extends ConsumerState } authProvider.dataInit = true; + + // Trigger health sync after weight entries are loaded (non-blocking) + final weightProviderForSync = context.read(); + final healthNotifier = ref.read(healthSyncProvider.notifier); + healthNotifier.syncOnAppOpen(existingEntries: weightProviderForSync.items).then((syncCount) { + if (syncCount > 0) { + weightProviderForSync.fetchAndSetEntries(); + } + }); } @override diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 014b281d9..3469acd73 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -21,6 +21,7 @@ import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/screens/settings_plates_screen.dart'; import './settings/exercise_cache.dart'; +import './settings/health_sync.dart'; import './settings/ingredient_cache.dart'; import './settings/theme.dart'; @@ -42,6 +43,8 @@ class SettingsPage extends StatelessWidget { ), const SettingsExerciseCache(), const SettingsIngredientCache(), + ListTile(title: Text('Health', style: Theme.of(context).textTheme.headlineSmall)), + const HealthSyncSettingsTile(), ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)), const SettingsTheme(), ListTile( diff --git a/lib/widgets/core/settings/health_sync.dart b/lib/widgets/core/settings/health_sync.dart new file mode 100644 index 000000000..40fac6031 --- /dev/null +++ b/lib/widgets/core/settings/health_sync.dart @@ -0,0 +1,77 @@ +/* + * 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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/providers/health_sync.dart'; + +class HealthSyncSettingsTile extends ConsumerStatefulWidget { + const HealthSyncSettingsTile({super.key}); + + @override + ConsumerState createState() => _HealthSyncSettingsTileState(); +} + +class _HealthSyncSettingsTileState extends ConsumerState { + bool? _isAvailable; + + @override + void initState() { + super.initState(); + _checkAvailability(); + } + + Future _checkAvailability() async { + final notifier = ref.read(healthSyncProvider.notifier); + final available = await notifier.isAvailable(); + if (mounted) { + setState(() => _isAvailable = available); + } + } + + @override + Widget build(BuildContext context) { + // Hide entirely if platform check hasn't completed or is unavailable + if (_isAvailable == null || _isAvailable == false) { + return const SizedBox.shrink(); + } + + final syncState = ref.watch(healthSyncProvider); + + return SwitchListTile( + title: const Text('Health sync'), + subtitle: const Text('Import weight from Apple Health or Health Connect'), + value: syncState.isEnabled, + onChanged: syncState.isSyncing + ? null + : (enabled) async { + final notifier = ref.read(healthSyncProvider.notifier); + if (enabled) { + final count = await notifier.enableSync(); + if (context.mounted && count > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Synced $count weight entries from Health')), + ); + } + } else { + await notifier.disableSync(); + } + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index ab5661fdc..5a89f2a0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.2" + carp_serializable: + dependency: transitive + description: + name: carp_serializable + sha256: f039f8ea22e9437aef13fe7e9743c3761c76d401288dcb702eadd273c3e4dcef + url: "https://pub.dev" + source: hosted + version: "2.0.1" change: dependency: transitive description: @@ -297,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" drift: dependency: "direct main" description: @@ -615,6 +639,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + health: + dependency: "direct main" + description: + name: health + sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524" + url: "https://pub.dev" + source: hosted + version: "13.3.1" hooks: dependency: transitive description: @@ -1649,6 +1681,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 036307e83..c99b5c904 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: font_awesome_flutter: ^11.0.0 freezed_annotation: ^3.0.0 get_it: ^8.3.0 + health: ^13.3.1 http: ^1.6.0 image_picker: ^1.2.1 intl: ^0.20.0 diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index f4869b4e6..003fb7dee 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_test.dart @@ -17,6 +17,7 @@ */ import 'package:flutter/material.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'; @@ -57,17 +58,21 @@ void main() { }); Widget createSettingsScreen({locale = 'en'}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (context) => mockNutritionProvider), - ChangeNotifierProvider(create: (context) => mockExerciseProvider), - ChangeNotifierProvider(create: (context) => mockUserProvider), - ], - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const SettingsPage(), + return ProviderScope( + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => mockNutritionProvider, + ), + ChangeNotifierProvider(create: (context) => mockExerciseProvider), + ChangeNotifierProvider(create: (context) => mockUserProvider), + ], + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SettingsPage(), + ), ), ); } diff --git a/test/weight/weight_model_test.dart b/test/weight/weight_model_test.dart index ebc283246..e6e6eb0d1 100644 --- a/test/weight/weight_model_test.dart +++ b/test/weight/weight_model_test.dart @@ -59,5 +59,19 @@ void main() { expect(weightModel.weight, 80); expect(weightModel.date, DateTime.utc(2020, 10, 01)); }); + + test('copyWith preserves decimal weight values', () { + final entry = WeightEntry(id: 1, weight: 80.5, date: DateTime.utc(2020, 10, 01)); + final copied = entry.copyWith(weight: 81.3); + + expect(copied.weight, 81.3); + }); + + test('copyWith with null weight keeps original value', () { + final entry = WeightEntry(id: 1, weight: 80.5, date: DateTime.utc(2020, 10, 01)); + final copied = entry.copyWith(); + + expect(copied.weight, 80.5); + }); }); } diff --git a/test/weight/weight_provider_test.dart b/test/weight/weight_provider_test.dart index 61ceffe80..e21d22094 100644 --- a/test/weight/weight_provider_test.dart +++ b/test/weight/weight_provider_test.dart @@ -88,6 +88,23 @@ void main() { expect(weightEntryNew.weight, 80); }); + test('findByDate matches by calendar date regardless of time', () { + final provider = BodyWeightProvider(mockBaseProvider); + provider.items = [ + WeightEntry(id: 1, weight: 80, date: DateTime(2021, 3, 15, 7, 15)), + WeightEntry(id: 2, weight: 81, date: DateTime(2021, 3, 16, 20, 0)), + ]; + + // Same calendar date, different time + final found = provider.findByDate(DateTime(2021, 3, 15, 22, 30)); + expect(found, isNotNull); + expect(found!.id, 1); + + // No entry for this date + final notFound = provider.findByDate(DateTime(2021, 3, 17)); + expect(notFound, isNull); + }); + test('Test deleting an existing weight entry', () async { // Arrange final uri = Uri( From 7bab9b617ef44ae0765ec4a1e1b4891ea10c6b88 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 15:44:34 -0500 Subject: [PATCH 02/22] feat: add unit label to weight form and convert health sync values - Show "Weight (kg)" or "Weight (lb)" on the weight entry form based on the user's profile preference - Convert health sync values from kg to lb before POSTing when the user's profile is set to lb - Check/request health permissions on sync to handle app restart - Fix weight form tests to provide UserProvider mock Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/providers/health_sync.dart | 33 +++- lib/screens/home_tabs_screen.dart | 6 +- lib/widgets/weight/forms.dart | 8 +- test/weight/weight_form_test.dart | 28 +++- test/weight/weight_form_test.mocks.dart | 211 ++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 test/weight/weight_form_test.mocks.dart diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart index 2bcfde48c..2ad16b152 100644 --- a/lib/providers/health_sync.dart +++ b/lib/providers/health_sync.dart @@ -55,6 +55,9 @@ class HealthSyncState { /// Initial sync lookback period const int healthSyncInitialDays = 30; +/// Conversion factor from kg to lb +const double kgToLb = 2.20462; + @Riverpod(keepAlive: true) class HealthSyncNotifier extends _$HealthSyncNotifier { final _logger = Logger('HealthSyncNotifier'); @@ -121,8 +124,9 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { state = const HealthSyncState(); } - /// Main sync method: read weight data from health platform, post new entries to backend - Future syncOnAppOpen({List? existingEntries}) async { + /// Main sync method: read weight data from health platform, post new entries to backend. + /// If [isMetric] is false, converts kg values from the health platform to lb before POSTing. + Future syncOnAppOpen({List? existingEntries, bool isMetric = true}) async { final prefs = PreferenceHelper.instance; final enabled = await prefs.getHealthSyncEnabled(); if (!enabled) { @@ -137,6 +141,23 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { try { await _health.configure(); + // Ensure we have permission to read weight data + final hasPerms = await _health.hasPermissions( + [HealthDataType.WEIGHT], + permissions: [HealthDataAccess.READ], + ); + if (hasPerms != true) { + final authorized = await _health.requestAuthorization( + [HealthDataType.WEIGHT], + permissions: [HealthDataAccess.READ], + ); + if (!authorized) { + _logger.warning('Health permissions not granted during sync'); + state = state.copyWith(isSyncing: false); + return 0; + } + } + // Determine the start time for the query final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); final DateTime startTime; @@ -171,9 +192,13 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { for (final point in dataPoints) { try { final value = (point.value as NumericHealthValue).numericValue; - final weightKg = (value * 100).roundToDouble() / 100; // Round to 2 decimal places + final weightKg = value.toDouble(); final timestamp = point.dateFrom; + // Convert to user's preferred unit + final weight = isMetric ? weightKg : weightKg * kgToLb; + final weightRounded = (weight * 100).roundToDouble() / 100; + // Fallback dedup: skip if an entry with the same timestamp exists locally if (existingEntries != null) { final duplicate = existingEntries.any( @@ -191,7 +216,7 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { } // POST to backend with original timestamp - final entry = WeightEntry(weight: weightKg, date: timestamp); + final entry = WeightEntry(weight: weightRounded, date: timestamp); await _baseProvider.post( entry.toJson(), _baseProvider.makeUrl(_weightEntryUrl), diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index 8371cd98c..7dc2c9787 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -151,8 +151,12 @@ class _HomeTabsScreenState extends ConsumerState // Trigger health sync after weight entries are loaded (non-blocking) final weightProviderForSync = context.read(); + final userProviderForSync = context.read(); + final isMetric = userProviderForSync.profile?.isMetric ?? true; final healthNotifier = ref.read(healthSyncProvider.notifier); - healthNotifier.syncOnAppOpen(existingEntries: weightProviderForSync.items).then((syncCount) { + healthNotifier + .syncOnAppOpen(existingEntries: weightProviderForSync.items, isMetric: isMetric) + .then((syncCount) { if (syncCount > 0) { weightProviderForSync.fetchAndSetEntries(); } diff --git a/lib/widgets/weight/forms.dart b/lib/widgets/weight/forms.dart index c3e182554..34eeffbb0 100644 --- a/lib/widgets/weight/forms.dart +++ b/lib/widgets/weight/forms.dart @@ -24,6 +24,8 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/body_weight/weight_entry.dart'; import 'package:wger/providers/body_weight.dart'; +import 'package:wger/providers/user.dart'; +import 'package:wger/widgets/measurements/charts.dart'; class WeightForm extends StatelessWidget { final _form = GlobalKey(); @@ -41,6 +43,10 @@ class WeightForm extends StatelessWidget { final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); + final profile = context.read().profile; + final unitLabel = profile != null + ? weightUnit(profile.isMetric, context) + : AppLocalizations.of(context).kg; if (weightController.text.isEmpty && _weightEntry.weight != 0) { weightController.text = numberFormat.format(_weightEntry.weight); @@ -127,7 +133,7 @@ class WeightForm extends StatelessWidget { TextFormField( key: const Key('weightInput'), decoration: InputDecoration( - labelText: AppLocalizations.of(context).weight, + labelText: '${AppLocalizations.of(context).weight} ($unitLabel)', prefix: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/test/weight/weight_form_test.dart b/test/weight/weight_form_test.dart index 0a821a377..6e5a8140c 100644 --- a/test/weight/weight_form_test.dart +++ b/test/weight/weight_form_test.dart @@ -18,19 +18,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/models/user/profile.dart'; +import 'package:wger/providers/user.dart'; import 'package:wger/widgets/weight/forms.dart'; import '../../test_data/body_weight.dart'; +import '../../test_data/profile.dart'; +import 'weight_form_test.mocks.dart'; +@GenerateMocks([UserProvider]) void main() { + late MockUserProvider mockUserProvider; + + setUp(() { + mockUserProvider = MockUserProvider(); + when(mockUserProvider.profile).thenReturn(tProfile1); + }); + Widget createWeightForm({locale = 'en', weightEntry = WeightEntry}) { - return MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: WeightForm(weightEntry)), + return ChangeNotifierProvider.value( + value: mockUserProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: WeightForm(weightEntry)), + ), ); } diff --git a/test/weight/weight_form_test.mocks.dart b/test/weight/weight_form_test.mocks.dart new file mode 100644 index 000000000..a998889d4 --- /dev/null +++ b/test/weight/weight_form_test.mocks.dart @@ -0,0 +1,211 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/weight/weight_form_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i7; +import 'dart:ui' as _i8; + +import 'package:flutter/material.dart' as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences/shared_preferences.dart' as _i3; +import 'package:wger/models/user/profile.dart' as _i6; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/user.dart' as _i4; + +// 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 + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake + implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSharedPreferencesAsync_1 extends _i1.SmartFake + implements _i3.SharedPreferencesAsync { + _FakeSharedPreferencesAsync_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [UserProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserProvider extends _i1.Mock implements _i4.UserProvider { + MockUserProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.ThemeMode get themeMode => + (super.noSuchMethod( + Invocation.getter(#themeMode), + returnValue: _i5.ThemeMode.system, + ) + as _i5.ThemeMode); + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + _i3.SharedPreferencesAsync get prefs => + (super.noSuchMethod( + Invocation.getter(#prefs), + returnValue: _FakeSharedPreferencesAsync_1( + this, + Invocation.getter(#prefs), + ), + ) + as _i3.SharedPreferencesAsync); + + @override + List<_i4.DashboardWidget> get dashboardWidgets => + (super.noSuchMethod( + Invocation.getter(#dashboardWidgets), + returnValue: <_i4.DashboardWidget>[], + ) + as List<_i4.DashboardWidget>); + + @override + List<_i4.DashboardWidget> get allDashboardWidgets => + (super.noSuchMethod( + Invocation.getter(#allDashboardWidgets), + returnValue: <_i4.DashboardWidget>[], + ) + as List<_i4.DashboardWidget>); + + @override + set themeMode(_i5.ThemeMode? value) => super.noSuchMethod( + Invocation.setter(#themeMode, value), + returnValueForMissingStub: null, + ); + + @override + set prefs(_i3.SharedPreferencesAsync? value) => super.noSuchMethod( + Invocation.setter(#prefs, value), + returnValueForMissingStub: null, + ); + + @override + set profile(_i6.Profile? value) => super.noSuchMethod( + Invocation.setter(#profile, value), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) + as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + bool isDashboardWidgetVisible(_i4.DashboardWidget? key) => + (super.noSuchMethod( + Invocation.method(#isDashboardWidgetVisible, [key]), + returnValue: false, + ) + as bool); + + @override + _i7.Future setDashboardWidgetVisible( + _i4.DashboardWidget? key, + bool? visible, + ) => + (super.noSuchMethod( + Invocation.method(#setDashboardWidgetVisible, [key, visible]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future setDashboardOrder(int? oldIndex, int? newIndex) => + (super.noSuchMethod( + Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + void setThemeMode(_i5.ThemeMode? mode) => super.noSuchMethod( + Invocation.method(#setThemeMode, [mode]), + returnValueForMissingStub: null, + ); + + @override + _i7.Future fetchAndSetProfile() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetProfile, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future saveProfile() => + (super.noSuchMethod( + Invocation.method(#saveProfile, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future verifyEmail() => + (super.noSuchMethod( + Invocation.method(#verifyEmail, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} From 627b8b9fd834f50df010d0f18e048c2936a8bee4 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:32:53 -0500 Subject: [PATCH 03/22] fix: unit conversion on enable, dashboard refresh, and permission check - Pass isMetric to enableSync() so the initial sync from the settings toggle converts kg to lb when the user's profile is set to lb - Refresh BodyWeightProvider after sync from settings tile so the dashboard and weight screen update immediately - Fix DashboardWeightWidget to compute sensibleRange() inside the Consumer builder so it rebuilds when weight data changes - Add permission check in syncOnAppOpen() for app restart scenarios - Add unit tests for weight conversion logic and HealthSyncState Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/providers/health_sync.dart | 7 +- lib/widgets/core/settings/health_sync.dart | 18 ++- lib/widgets/dashboard/widgets/weight.dart | 175 +++++++++++---------- test/providers/health_sync_test.dart | 83 ++++++++++ 4 files changed, 189 insertions(+), 94 deletions(-) create mode 100644 test/providers/health_sync_test.dart diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart index 2ad16b152..098972273 100644 --- a/lib/providers/health_sync.dart +++ b/lib/providers/health_sync.dart @@ -95,8 +95,9 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { return Platform.isIOS; } - /// Enable health sync: request permissions, save preference, trigger initial sync - Future enableSync() async { + /// Enable health sync: request permissions, save preference, trigger initial sync. + /// If [isMetric] is false, converts kg values from the health platform to lb before POSTing. + Future enableSync({bool isMetric = true}) async { _logger.info('Enabling health sync'); await _health.configure(); @@ -114,7 +115,7 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { await PreferenceHelper.instance.setHealthSyncEnabled(true); state = state.copyWith(isEnabled: true); - return syncOnAppOpen(); + return syncOnAppOpen(isMetric: isMetric); } /// Disable health sync: clear preferences diff --git a/lib/widgets/core/settings/health_sync.dart b/lib/widgets/core/settings/health_sync.dart index 40fac6031..9622e8f94 100644 --- a/lib/widgets/core/settings/health_sync.dart +++ b/lib/widgets/core/settings/health_sync.dart @@ -18,7 +18,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:provider/provider.dart' as provider; +import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/health_sync.dart'; +import 'package:wger/providers/user.dart'; class HealthSyncSettingsTile extends ConsumerStatefulWidget { const HealthSyncSettingsTile({super.key}); @@ -62,11 +65,18 @@ class _HealthSyncSettingsTileState extends ConsumerState : (enabled) async { final notifier = ref.read(healthSyncProvider.notifier); if (enabled) { - final count = await notifier.enableSync(); + final profile = provider.Provider.of(context, listen: false).profile; + final isMetric = profile?.isMetric ?? true; + final count = await notifier.enableSync(isMetric: isMetric); if (context.mounted && count > 0) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Synced $count weight entries from Health')), - ); + // Refresh weight entries so the dashboard/weight screen updates + await provider.Provider.of(context, listen: false) + .fetchAndSetEntries(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Synced $count weight entries from Health')), + ); + } } } else { await notifier.disableSync(); diff --git a/lib/widgets/dashboard/widgets/weight.dart b/lib/widgets/dashboard/widgets/weight.dart index d13e14199..23dee28c5 100644 --- a/lib/widgets/dashboard/widgets/weight.dart +++ b/lib/widgets/dashboard/widgets/weight.dart @@ -35,101 +35,102 @@ class DashboardWeightWidget extends StatelessWidget { @override Widget build(BuildContext context) { final profile = context.read().profile; - final weightProvider = context.read(); - - final (entriesAll, entries7dAvg) = sensibleRange( - weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(), - ); return Consumer( - builder: (context, _, _) => Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).weight, - style: Theme.of(context).textTheme.headlineSmall, - ), - leading: FaIcon( - FontAwesomeIcons.weightScale, - color: Theme.of(context).textTheme.headlineSmall!.color, + builder: (context, weightProvider, _) { + final (entriesAll, entries7dAvg) = sensibleRange( + weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(), + ); + + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + AppLocalizations.of(context).weight, + style: Theme.of(context).textTheme.headlineSmall, + ), + leading: FaIcon( + FontAwesomeIcons.weightScale, + color: Theme.of(context).textTheme.headlineSmall!.color, + ), ), - ), - Column( - children: [ - if (weightProvider.items.isNotEmpty) - Column( - children: [ - SizedBox( - height: 200, - child: MeasurementChartWidgetFl( - entriesAll, - weightUnit(profile!.isMetric, context), - avgs: entries7dAvg, + Column( + children: [ + if (weightProvider.items.isNotEmpty) + Column( + children: [ + SizedBox( + height: 200, + child: MeasurementChartWidgetFl( + entriesAll, + weightUnit(profile!.isMetric, context), + avgs: entries7dAvg, + ), ), - ), - if (entries7dAvg.isNotEmpty) - MeasurementOverallChangeWidget( - entries7dAvg.first, - entries7dAvg.last, - weightUnit(profile.isMetric, context), - ), - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text( - AppLocalizations.of(context).goToDetailPage, - overflow: TextOverflow.ellipsis, + if (entries7dAvg.isNotEmpty) + MeasurementOverallChangeWidget( + entries7dAvg.first, + entries7dAvg.last, + weightUnit(profile.isMetric, context), + ), + LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + child: Text( + AppLocalizations.of(context).goToDetailPage, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + Navigator.of(context).pushNamed(WeightScreen.routeName); + }, ), - onPressed: () { - Navigator.of(context).pushNamed(WeightScreen.routeName); - }, - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - WeightForm( - weightProvider.getNewestEntry()?.copyWith( - id: null, - date: DateTime.now(), + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + WeightForm( + weightProvider.getNewestEntry()?.copyWith( + id: null, + date: DateTime.now(), + ), ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), - ), - ); - }, - ), - ], - ) - else - NothingFound( - AppLocalizations.of(context).noWeightEntries, - AppLocalizations.of(context).newEntry, - WeightForm(), - ), - ], - ), - ], - ), - ), + ); + }, + ), + ], + ) + else + NothingFound( + AppLocalizations.of(context).noWeightEntries, + AppLocalizations.of(context).newEntry, + WeightForm(), + ), + ], + ), + ], + ), + ); + }, ); } } diff --git a/test/providers/health_sync_test.dart b/test/providers/health_sync_test.dart new file mode 100644 index 000000000..73b23d460 --- /dev/null +++ b/test/providers/health_sync_test.dart @@ -0,0 +1,83 @@ +/* + * 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:flutter_test/flutter_test.dart'; +import 'package:wger/providers/health_sync.dart'; + +/// Mirrors the conversion logic in HealthSyncNotifier.syncOnAppOpen +double _convertWeight(double weightKg, {required bool isMetric}) { + return isMetric ? weightKg : weightKg * kgToLb; +} + +void main() { + group('Health sync constants', () { + test('kgToLb conversion factor is correct', () { + // 1 kg = 2.20462 lb + expect(kgToLb, closeTo(2.20462, 0.00001)); + }); + + test('Initial sync lookback is 30 days', () { + expect(healthSyncInitialDays, 30); + }); + }); + + group('Weight unit conversion', () { + test('kg value is converted to lb correctly', () { + const weightKg = 85.0; + final weightLb = (weightKg * kgToLb * 100).roundToDouble() / 100; + expect(weightLb, closeTo(187.39, 0.01)); + }); + + test('kg value stays as-is when metric', () { + const weightKg = 85.0; + final weight = _convertWeight(weightKg, isMetric: true); + expect(weight, 85.0); + }); + + test('kg value is converted when imperial', () { + const weightKg = 85.0; + final weight = _convertWeight(weightKg, isMetric: false); + expect(weight, closeTo(187.39, 0.01)); + }); + + test('conversion rounds to 2 decimal places', () { + const weightKg = 85.12345; + final weight = weightKg * kgToLb; + final rounded = (weight * 100).roundToDouble() / 100; + // 85.12345 * 2.20462 = 187.66... + expect(rounded.toString().split('.').last.length, lessThanOrEqualTo(2)); + }); + }); + + group('HealthSyncState', () { + test('default state has sync disabled', () { + const state = HealthSyncState(); + expect(state.isEnabled, false); + expect(state.isSyncing, false); + expect(state.lastSyncCount, 0); + }); + + test('copyWith updates individual fields', () { + const state = HealthSyncState(); + final updated = state.copyWith(isEnabled: true, lastSyncCount: 5); + expect(updated.isEnabled, true); + expect(updated.isSyncing, false); + expect(updated.lastSyncCount, 5); + }); + }); +} From 2f10ee9a5d4d9847fb52d16e776d825980d6d068 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:38:48 -0500 Subject: [PATCH 04/22] refactor: simplify health sync code after review - Use existing isSameDayAs() extension in findByDate() instead of manual year/month/day comparison - Build a Set of existing timestamps for O(1) dedup lookups instead of O(n*m) .any() scan per data point - Simplify nullable bool check (_isAvailable != true) - Remove comments that restate the code Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/providers/body_weight.dart | 6 ++-- lib/providers/health_sync.dart | 38 +++++++++++----------- lib/widgets/core/settings/health_sync.dart | 2 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lib/providers/body_weight.dart b/lib/providers/body_weight.dart index 8af2f4b3c..71c1dd5dc 100644 --- a/lib/providers/body_weight.dart +++ b/lib/providers/body_weight.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/date.dart'; import 'package:wger/models/body_weight/weight_entry.dart'; import 'package:wger/providers/base_provider.dart'; @@ -59,10 +60,7 @@ class BodyWeightProvider with ChangeNotifier { WeightEntry? findByDate(DateTime date) { try { return _entries.firstWhere( - (entry) => - entry.date.year == date.year && - entry.date.month == date.month && - entry.date.day == date.day, + (entry) => entry.date.isSameDayAs(date), ); } on StateError { return null; diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart index 098972273..056fe0437 100644 --- a/lib/providers/health_sync.dart +++ b/lib/providers/health_sync.dart @@ -52,10 +52,7 @@ class HealthSyncState { } } -/// Initial sync lookback period const int healthSyncInitialDays = 30; - -/// Conversion factor from kg to lb const double kgToLb = 2.20462; @Riverpod(keepAlive: true) @@ -187,6 +184,14 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { _logger.info('Found ${dataPoints.length} weight data points'); + // Build a Set of existing timestamps for O(1) dedup lookups + final existingTimestamps = existingEntries != null + ? { + for (final e in existingEntries) + DateTime(e.date.year, e.date.month, e.date.day, e.date.hour, e.date.minute), + } + : {}; + int syncedCount = 0; DateTime? latestSynced; @@ -196,27 +201,22 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { final weightKg = value.toDouble(); final timestamp = point.dateFrom; - // Convert to user's preferred unit final weight = isMetric ? weightKg : weightKg * kgToLb; final weightRounded = (weight * 100).roundToDouble() / 100; - // Fallback dedup: skip if an entry with the same timestamp exists locally - if (existingEntries != null) { - final duplicate = existingEntries.any( - (e) => - e.date.year == timestamp.year && - e.date.month == timestamp.month && - e.date.day == timestamp.day && - e.date.hour == timestamp.hour && - e.date.minute == timestamp.minute, - ); - if (duplicate) { - _logger.fine('Skipping duplicate entry for $timestamp'); - continue; - } + // Skip if an entry with the same timestamp already exists locally + final normalizedTimestamp = DateTime( + timestamp.year, + timestamp.month, + timestamp.day, + timestamp.hour, + timestamp.minute, + ); + if (existingTimestamps.contains(normalizedTimestamp)) { + _logger.fine('Skipping duplicate entry for $timestamp'); + continue; } - // POST to backend with original timestamp final entry = WeightEntry(weight: weightRounded, date: timestamp); await _baseProvider.post( entry.toJson(), diff --git a/lib/widgets/core/settings/health_sync.dart b/lib/widgets/core/settings/health_sync.dart index 9622e8f94..e9902355d 100644 --- a/lib/widgets/core/settings/health_sync.dart +++ b/lib/widgets/core/settings/health_sync.dart @@ -50,7 +50,7 @@ class _HealthSyncSettingsTileState extends ConsumerState @override Widget build(BuildContext context) { // Hide entirely if platform check hasn't completed or is unavailable - if (_isAvailable == null || _isAvailable == false) { + if (_isAvailable != true) { return const SizedBox.shrink(); } From 86246ac947131d0b03d7f764b9b70979c44a4fb3 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:42:08 -0500 Subject: [PATCH 05/22] refactor: use l10n for health sync user-facing strings Add healthSync, healthSyncDescription, healthSyncSuccess, and health keys to app_en.arb and use AppLocalizations instead of hardcoded English strings in the settings tile and settings page. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/l10n/app_en.arb | 23 +++++++++++++++++++++- lib/widgets/core/settings.dart | 2 +- lib/widgets/core/settings/health_sync.dart | 9 ++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d07fa2765..891a75dfa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1180,5 +1180,26 @@ } }, "searchLanguageAll": "All languages", - "@searchLanguageAll": {} + "@searchLanguageAll": {}, + "healthSync": "Health sync", + "@healthSync": { + "description": "Title for the health platform sync setting" + }, + "healthSyncDescription": "Import weight from Apple Health or Health Connect", + "@healthSyncDescription": { + "description": "Subtitle for the health platform sync setting" + }, + "healthSyncSuccess": "Synced {count} weight entries from Health", + "@healthSyncSuccess": { + "description": "Snackbar message after successful health sync", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "health": "Health", + "@health": { + "description": "Section header for health-related settings" + } } diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 3469acd73..d4208bdd1 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -43,7 +43,7 @@ class SettingsPage extends StatelessWidget { ), const SettingsExerciseCache(), const SettingsIngredientCache(), - ListTile(title: Text('Health', style: Theme.of(context).textTheme.headlineSmall)), + ListTile(title: Text(i18n.health, style: Theme.of(context).textTheme.headlineSmall)), const HealthSyncSettingsTile(), ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)), const SettingsTheme(), diff --git a/lib/widgets/core/settings/health_sync.dart b/lib/widgets/core/settings/health_sync.dart index e9902355d..da738923f 100644 --- a/lib/widgets/core/settings/health_sync.dart +++ b/lib/widgets/core/settings/health_sync.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:provider/provider.dart' as provider; +import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/health_sync.dart'; import 'package:wger/providers/user.dart'; @@ -56,9 +57,11 @@ class _HealthSyncSettingsTileState extends ConsumerState final syncState = ref.watch(healthSyncProvider); + final i18n = AppLocalizations.of(context); + return SwitchListTile( - title: const Text('Health sync'), - subtitle: const Text('Import weight from Apple Health or Health Connect'), + title: Text(i18n.healthSync), + subtitle: Text(i18n.healthSyncDescription), value: syncState.isEnabled, onChanged: syncState.isSyncing ? null @@ -74,7 +77,7 @@ class _HealthSyncSettingsTileState extends ConsumerState .fetchAndSetEntries(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Synced $count weight entries from Health')), + SnackBar(content: Text(i18n.healthSyncSuccess(count))), ); } } From a4d03bcc46422ec903a9b1ae02dd6b74994dd330 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:44:43 -0500 Subject: [PATCH 06/22] feat: import all health history instead of last 30 days Remove the 30-day lookback limit on initial sync. Pull all available weight data from Health Connect on first enable. Add READ_HEALTH_DATA_HISTORY permission to AndroidManifest to allow reading data older than 30 days. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/src/main/AndroidManifest.xml | 1 + lib/providers/health_sync.dart | 4 ++-- test/providers/health_sync_test.dart | 3 --- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2f8ef5362..b23873870 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart index 056fe0437..0ac784b55 100644 --- a/lib/providers/health_sync.dart +++ b/lib/providers/health_sync.dart @@ -52,7 +52,6 @@ class HealthSyncState { } } -const int healthSyncInitialDays = 30; const double kgToLb = 2.20462; @Riverpod(keepAlive: true) @@ -162,7 +161,8 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { if (lastSyncStr != null) { startTime = DateTime.parse(lastSyncStr); } else { - startTime = DateTime.now().subtract(const Duration(days: healthSyncInitialDays)); + // Pull all available history on first sync + startTime = DateTime(2000); } final endTime = DateTime.now(); diff --git a/test/providers/health_sync_test.dart b/test/providers/health_sync_test.dart index 73b23d460..1062a5688 100644 --- a/test/providers/health_sync_test.dart +++ b/test/providers/health_sync_test.dart @@ -31,9 +31,6 @@ void main() { expect(kgToLb, closeTo(2.20462, 0.00001)); }); - test('Initial sync lookback is 30 days', () { - expect(healthSyncInitialDays, 30); - }); }); group('Weight unit conversion', () { From e1968d0b2f87d95ed6481826b7dde250e0104019 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:53:49 -0500 Subject: [PATCH 07/22] fix: request historical data access from Health Connect Call requestHealthDataHistoryAuthorization() on Android after initial permission grant. Without this runtime request, Health Connect limits data access to the last 30 days regardless of the manifest permission. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/providers/health_sync.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart index 0ac784b55..1198ff54c 100644 --- a/lib/providers/health_sync.dart +++ b/lib/providers/health_sync.dart @@ -108,6 +108,11 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { return 0; } + // Request access to historical data (older than 30 days) on Android + if (Platform.isAndroid) { + await _health.requestHealthDataHistoryAuthorization(); + } + await PreferenceHelper.instance.setHealthSyncEnabled(true); state = state.copyWith(isEnabled: true); From 7001bca7396a461367eb930fe406117a24ccfcff Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Sat, 16 May 2026 16:18:28 +0545 Subject: [PATCH 08/22] feat(measurements): implement measurement groups, dynamic formula evaluation --- .../measurements/measurement_category.dart | 27 +- .../measurements/measurement_category.g.dart | 10 + .../measurements/measurement_group.dart | 37 ++ .../measurements/measurement_group.g.dart | 25 ++ lib/providers/measurement.dart | 117 ++++- .../measurement_categories_screen.dart | 47 +- lib/screens/measurement_entries_screen.dart | 30 +- lib/widgets/measurements/categories.dart | 12 +- lib/widgets/measurements/categories_card.dart | 59 ++- lib/widgets/measurements/entries.dart | 4 +- lib/widgets/measurements/forms.dart | 418 ++++++++++++------ 11 files changed, 620 insertions(+), 166 deletions(-) create mode 100644 lib/models/measurements/measurement_group.dart create mode 100644 lib/models/measurements/measurement_group.g.dart diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index 05d6ade02..eb268b0e5 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; part 'measurement_category.g.dart'; @@ -16,6 +17,18 @@ class MeasurementCategory extends Equatable { @JsonKey(required: true) final String unit; + @JsonKey(name: 'group', includeToJson: true) + final int? groupId; + + @JsonKey(name: 'group_detail', includeToJson: false) + final MeasurementGroup? groupDetail; + + @JsonKey(name: 'formula', includeToJson: true) + final String? formula; + + @JsonKey(name: 'is_dynamic', includeToJson: false, defaultValue: false) + final bool isDynamic; + @JsonKey(defaultValue: [], toJson: _nullValue) final List entries; @@ -23,6 +36,10 @@ class MeasurementCategory extends Equatable { required this.id, required this.name, required this.unit, + this.groupId, + this.groupDetail, + this.formula, + this.isDynamic = false, this.entries = const [], }); @@ -30,12 +47,20 @@ class MeasurementCategory extends Equatable { int? id, String? name, String? unit, + int? groupId, + MeasurementGroup? groupDetail, + String? formula, + bool? isDynamic, List? entries, }) { return MeasurementCategory( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, + groupId: groupId ?? this.groupId, + groupDetail: groupDetail ?? this.groupDetail, + formula: formula ?? this.formula, + isDynamic: isDynamic ?? this.isDynamic, entries: entries ?? this.entries, ); } @@ -54,7 +79,7 @@ class MeasurementCategory extends Equatable { Map toJson() => _$MeasurementCategoryToJson(this); @override - List get props => [id, name, unit, entries]; + List get props => [id, name, unit, groupId, formula, isDynamic, entries]; // Helper function which makes the entries list of the toJson output null, as it isn't needed static Null _nullValue(List _) => null; diff --git a/lib/models/measurements/measurement_category.g.dart b/lib/models/measurements/measurement_category.g.dart index e971c3884..8ead8e403 100644 --- a/lib/models/measurements/measurement_category.g.dart +++ b/lib/models/measurements/measurement_category.g.dart @@ -12,6 +12,14 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map json) { id: (json['id'] as num?)?.toInt(), name: json['name'] as String, unit: json['unit'] as String, + groupId: (json['group'] as num?)?.toInt(), + groupDetail: json['group_detail'] == null + ? null + : MeasurementGroup.fromJson( + json['group_detail'] as Map, + ), + formula: json['formula'] as String?, + isDynamic: json['is_dynamic'] as bool? ?? false, entries: (json['entries'] as List?) ?.map((e) => MeasurementEntry.fromJson(e as Map)) @@ -26,5 +34,7 @@ Map _$MeasurementCategoryToJson( 'id': instance.id, 'name': instance.name, 'unit': instance.unit, + 'group': instance.groupId, + 'formula': instance.formula, 'entries': MeasurementCategory._nullValue(instance.entries), }; diff --git a/lib/models/measurements/measurement_group.dart b/lib/models/measurements/measurement_group.dart new file mode 100644 index 000000000..93fd211e5 --- /dev/null +++ b/lib/models/measurements/measurement_group.dart @@ -0,0 +1,37 @@ + +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'measurement_group.g.dart'; + +/// A named group linking related measurement categories together. +/// Example: "Blood Pressure" groups Systolic + Diastolic categories. +@JsonSerializable() +class MeasurementGroup extends Equatable { + @JsonKey(required: true) + final int id; + + @JsonKey(required: true) + final String uuid; + + @JsonKey(required: true) + final String name; + + @JsonKey(defaultValue: '') + final String description; + + const MeasurementGroup({ + required this.id, + required this.uuid, + required this.name, + this.description = '', + }); + + factory MeasurementGroup.fromJson(Map json) => + _$MeasurementGroupFromJson(json); + + Map toJson() => _$MeasurementGroupToJson(this); + + @override + List get props => [id, uuid, name, description]; +} \ No newline at end of file diff --git a/lib/models/measurements/measurement_group.g.dart b/lib/models/measurements/measurement_group.g.dart new file mode 100644 index 000000000..51fee1ae7 --- /dev/null +++ b/lib/models/measurements/measurement_group.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'measurement_group.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MeasurementGroup _$MeasurementGroupFromJson(Map json) { + $checkKeys(json, requiredKeys: const ['id', 'uuid', 'name']); + return MeasurementGroup( + id: (json['id'] as num).toInt(), + uuid: json['uuid'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + ); +} + +Map _$MeasurementGroupToJson(MeasurementGroup instance) => + { + 'id': instance.id, + 'uuid': instance.uuid, + 'name': instance.name, + 'description': instance.description, + }; diff --git a/lib/providers/measurement.dart b/lib/providers/measurement.dart index 6c0292f0d..213967177 100644 --- a/lib/providers/measurement.dart +++ b/lib/providers/measurement.dart @@ -23,6 +23,7 @@ import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; import 'package:wger/providers/base_provider.dart'; class MeasurementProvider with ChangeNotifier { @@ -30,18 +31,22 @@ class MeasurementProvider with ChangeNotifier { static const _categoryUrl = 'measurement-category'; static const _entryUrl = 'measurement'; - + static const _groupUrl = 'measurement-group'; + static const _dynamicValuesAction = 'dynamic-values'; final WgerBaseProvider baseProvider; List _categories = []; + List _groups = []; MeasurementProvider(this.baseProvider); List get categories => _categories; + List get groups => _groups; /// Clears all lists void clear() { _categories = []; + _groups = []; } /// Finds the category by ID @@ -69,6 +74,12 @@ class MeasurementProvider with ChangeNotifier { /// Fetches and sets the measurement entries for the given category Future fetchAndSetCategoryEntries(int id) async { final category = findCategoryById(id); + + if (category.isDynamic) { + await fetchDynamicCategoryEntries(id); + return; + } + final categoryIndex = _categories.indexOf(category); // Process the response @@ -87,10 +98,39 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } + /// Fetches computed values for a dynamic (formula-based) category + /// and sets the measurement entries + Future fetchDynamicCategoryEntries(int categoryId) async { + final category = findCategoryById(categoryId); + if (!category.isDynamic) return; + + final categoryIndex = _categories.indexOf(category); + + final requestUrl = baseProvider.makeUrl( + '$_categoryUrl/$categoryId/$_dynamicValuesAction', + ); + + // The dynamic-values endpoint returns a plain list, not paginated. + final dynamic rawData = await baseProvider.fetch(requestUrl); + final List data = rawData is List ? rawData : (rawData['results'] ?? []); + + final List computedEntries = data + .map((e) => MeasurementEntry.fromJson(e as Map)) + .toList(); + + final MeasurementCategory editedCategory = category.copyWith( + entries: computedEntries, + ); + _categories.removeAt(categoryIndex); + _categories.insert(categoryIndex, editedCategory); + notifyListeners(); + } + /// Fetches and sets the measurement categories and their entries Future fetchAndSetAllCategoriesAndEntries() async { _logger.info('Fetching all measurement categories and entries'); + await fetchAndSetGroups(); await fetchAndSetCategories(); await Future.wait(_categories.map((e) => fetchAndSetCategoryEntries(e.id!)).toList()); } @@ -124,7 +164,15 @@ class MeasurementProvider with ChangeNotifier { /// Edits a measurement category /// Currently there isn't any fallback if the call to the api is unsuccessful, as WgerBaseProvider.patch only returns the response body and not the whole response - Future editCategory(int id, String? newName, String? newUnit) async { + Future editCategory( + int id, + String? newName, + String? newUnit, + int? newGroupId, + String? newFormula, { + bool clearGroup = false, + bool clearFormula = false, + }) async { final MeasurementCategory oldCategory = findCategoryById(id); final int categoryIndex = _categories.indexOf(oldCategory); final MeasurementCategory tempNewCategory = oldCategory.copyWith(name: newName, unit: newUnit); @@ -141,6 +189,71 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } + // --- Measurement Groups --- + + /// Finds a group by ID + MeasurementGroup findGroupById(int id) { + return _groups.firstWhere( + (group) => group.id == id, + orElse: () => throw const NoSuchEntryException(), + ); + } + + /// Fetches and sets all measurement groups from the server. + Future fetchAndSetGroups() async { + final requestUrl = baseProvider.makeUrl(_groupUrl, query: {'limit': API_MAX_PAGE_SIZE}); + final data = await baseProvider.fetchPaginated(requestUrl); + _groups = data.map((e) => MeasurementGroup.fromJson(e)).toList(); + notifyListeners(); + } + + /// Adds a measurement group. + Future addGroup(MeasurementGroup group) async { + final Uri postUri = baseProvider.makeUrl(_groupUrl); + final Map newGroupMap = await baseProvider.post(group.toJson(), postUri); + final MeasurementGroup newGroup = MeasurementGroup.fromJson(newGroupMap); + _groups.add(newGroup); + _groups.sort((a, b) => a.name.compareTo(b.name)); + notifyListeners(); + } + + /// Edits a measurement group name/description. + Future editGroup(int id, String? newName, String? newDescription) async { + final MeasurementGroup oldGroup = findGroupById(id); + final int groupIndex = _groups.indexOf(oldGroup); + final MeasurementGroup tempNew = MeasurementGroup( + id: oldGroup.id, + uuid: oldGroup.uuid, + name: newName ?? oldGroup.name, + description: newDescription ?? oldGroup.description, + ); + final Map response = await baseProvider.patch( + tempNew.toJson(), + baseProvider.makeUrl(_groupUrl, id: id), + ); + final MeasurementGroup newGroup = MeasurementGroup.fromJson(response); + _groups.removeAt(groupIndex); + _groups.insert(groupIndex, newGroup); + notifyListeners(); + } + + /// Deletes a measurement group. + Future deleteGroup(int id) async { + final MeasurementGroup group = findGroupById(id); + final int groupIndex = _groups.indexOf(group); + _groups.remove(group); + notifyListeners(); + try { + await baseProvider.deleteRequest(_groupUrl, id); + } on WgerHttpException { + _groups.insert(groupIndex, group); + notifyListeners(); + rethrow; + } + } + + // --- Measurement Entries --- + /// Adds a measurement entry Future addEntry(MeasurementEntry entry) async { final Uri postUri = baseProvider.makeUrl(_entryUrl); diff --git a/lib/screens/measurement_categories_screen.dart b/lib/screens/measurement_categories_screen.dart index 6feffb47a..5aa28a2a2 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -25,11 +25,19 @@ import 'package:wger/screens/form_screen.dart'; import 'package:wger/widgets/measurements/categories.dart'; import 'package:wger/widgets/measurements/forms.dart'; -class MeasurementCategoriesScreen extends StatelessWidget { +class MeasurementCategoriesScreen extends StatefulWidget { + const MeasurementCategoriesScreen(); static const routeName = '/measurement-categories'; + @override + State createState() => _MeasurementCategoriesScreenState(); +} + +class _MeasurementCategoriesScreenState extends State { + int? _selectedGroupId; + @override Widget build(BuildContext context) { return Scaffold( @@ -49,7 +57,42 @@ class MeasurementCategoriesScreen extends StatelessWidget { ), body: WidescreenWrapper( child: Consumer( - builder: (context, provider, child) => const CategoriesList(), + builder: (context, provider, child) { + + final groups = provider.groups; + if (groups.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + // clears group filter + FilterChip( + label: const Text('All'), + selected: _selectedGroupId == null, + onSelected: (_) => setState(() => _selectedGroupId = null), + ), + const SizedBox(width: 6), + // One chip per group + ...groups.map( + (group) => Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(group.name), + selected: _selectedGroupId == group.id, + onSelected: (_) => setState( + () => _selectedGroupId = + _selectedGroupId == group.id ? null : group.id, + ), + ), + ), + ), + CategoriesList(_selectedGroupId), + ], + ), + ); + } ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart index 3f30ed3e3..b8a5728af 100644 --- a/lib/screens/measurement_entries_screen.dart +++ b/lib/screens/measurement_entries_screen.dart @@ -54,6 +54,32 @@ class MeasurementEntriesScreen extends StatelessWidget { return const SizedBox(); // Return empty widget until pop happens } + if (category.isDynamic) { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.auto_graph, color: Colors.blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Values for this category are calculated automatically ' + 'using the "${category.formula}" formula. ' + 'You cannot add entries manually.', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } + return Scaffold( appBar: AppBar( title: Text(category.name), @@ -68,7 +94,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).edit, - MeasurementCategoryForm(category), + MeasurementCategoryForm(), ), ); break; @@ -145,7 +171,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).newEntry, - MeasurementEntryForm(categoryId), + MeasurementEntryForm(categoryId: categoryId), ), ); }, diff --git a/lib/widgets/measurements/categories.dart b/lib/widgets/measurements/categories.dart index 32abf49e6..a00905175 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -23,17 +23,23 @@ import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; class CategoriesList extends StatelessWidget { - const CategoriesList(); + final int? _selectedGrouptID; + const CategoriesList(this._selectedGrouptID); + @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); + final categories = provider.categories.where((cat) { + if (_selectedGrouptID == null) return true; + return cat.groupId == _selectedGrouptID; + }).toList(); return RefreshIndicator( onRefresh: () => provider.fetchAndSetAllCategoriesAndEntries(), child: ListView.builder( padding: const EdgeInsets.all(10.0), - itemCount: provider.categories.length, - itemBuilder: (context, index) => CategoriesCard(provider.categories[index]), + itemCount: categories.length, + itemBuilder: (context, index) => CategoriesCard(categories[index]), ), ); } diff --git a/lib/widgets/measurements/categories_card.dart b/lib/widgets/measurements/categories_card.dart index 27ef09df2..c31401eb4 100644 --- a/lib/widgets/measurements/categories_card.dart +++ b/lib/widgets/measurements/categories_card.dart @@ -33,6 +33,35 @@ class CategoriesCard extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), ), + // Group + if (currentCategory.groupDetail != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Chip( + avatar: const Icon(Icons.link, size: 14), + label: Text( + currentCategory.groupDetail!.name, + style: const TextStyle(fontSize: 11), + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ), + // Dynamic + if (currentCategory.isDynamic) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Chip( + avatar: const Icon(Icons.auto_graph, size: 14, color: Colors.blue), + label: const Text( + 'Auto-calculated', + style: TextStyle(fontSize: 11, color: Colors.blue), + ), + backgroundColor: Colors.blue.withValues(alpha: 0.1), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ), Container( padding: const EdgeInsets.all(10), height: 220, @@ -68,19 +97,23 @@ class CategoriesCard extends StatelessWidget { ); }, ), - IconButton( - onPressed: () async { - await Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - MeasurementEntryForm(currentCategory.id!), - ), - ); - }, - icon: const Icon(Icons.add), - ), + // hide add-entry button for dynamic categories + if (!currentCategory.isDynamic) + IconButton( + onPressed: () async { + await Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + MeasurementEntryForm( + categoryId: currentCategory.id!, + ), + ), + ); + }, + icon: const Icon(Icons.add), + ), ], ), ), diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 10220dbe9..6985a1bc4 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -80,8 +80,8 @@ class EntriesList extends StatelessWidget { arguments: FormScreenArguments( AppLocalizations.of(context).edit, MeasurementEntryForm( - currentEntry.category, - currentEntry, + categoryId: currentEntry.category, + entry: currentEntry, ), ), ), diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 38734d2ac..b69f08015 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -23,33 +23,66 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; import 'package:wger/providers/measurement.dart'; -class MeasurementCategoryForm extends StatelessWidget { +class MeasurementCategoryForm extends StatefulWidget { + final MeasurementCategory? category; + + const MeasurementCategoryForm({ + super.key, + this.category, + }); + + @override + State createState() => _MeasurementCategoryFormState(); +} + +class _MeasurementCategoryFormState extends State { + final List _dummyGroups = [ + const MeasurementGroup(uuid: '1', id: 1, name: 'Dummy: Bodyweight'), + const MeasurementGroup(uuid: '2', id: 2, name: 'Dummy: Strength'), + ]; + final _form = GlobalKey(); - final nameController = TextEditingController(); - final unitController = TextEditingController(); + late final TextEditingController nameController; + late final TextEditingController unitController; + + int? _categoryId; + + int? _selectedGroupId; + String? _selectedFormula; + bool _isSubmitting = false; - final Map categoryData = { - 'id': null, - 'name': '', - 'unit': '', + static const Map _formulaLabels = { + 'bmi': 'Body Mass Index (BMI)', + 'lbm': 'Lean Body Mass', + '1rm_epley': '1RM — Epley formula', }; - MeasurementCategoryForm([MeasurementCategory? category]) { - //this._category = category ?? MeasurementCategory(); - if (category != null) { - categoryData['id'] = category.id; - categoryData['unit'] = category.unit; - categoryData['name'] = category.name; - } + @override + void initState() { + super.initState(); + final cat = widget.category; + _categoryId = cat?.id; + nameController = TextEditingController(text: cat?.name ?? ''); + unitController = TextEditingController(text: cat?.unit ?? ''); - unitController.text = categoryData['unit']!; - nameController.text = categoryData['name']!; + _selectedGroupId = cat?.groupId; + _selectedFormula = cat?.formula; + } + + @override + void dispose() { + nameController.dispose(); + unitController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + final groups = Provider.of(context, listen: false).groups; return Form( key: _form, child: Column( @@ -57,16 +90,13 @@ class MeasurementCategoryForm extends StatelessWidget { // Name TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).name, - helperText: AppLocalizations.of(context).measurementCategoriesHelpText, + labelText: i18n.name, + helperText: i18n.measurementCategoriesHelpText, ), controller: nameController, - onSaved: (newValue) { - categoryData['name'] = newValue; - }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.trim().isEmpty) { + return i18n.enterValue; } return null; }, @@ -75,53 +105,106 @@ class MeasurementCategoryForm extends StatelessWidget { // Unit TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).unit, - helperText: AppLocalizations.of(context).measurementEntriesHelpText, + labelText: i18n.unit, + helperText: i18n.measurementEntriesHelpText, ), controller: unitController, - onSaved: (newValue) { - categoryData['unit'] = newValue; - }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.trim().isEmpty) { + return i18n.enterValue; } return null; }, ), + + // Group dropdown + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _selectedGroupId, + decoration: const InputDecoration( + labelText: 'Group (optional)', + helperText: 'Link this category to a group e.g. "Blood Pressure"', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('- No group -'), + ), + ...groups.map( + (g) => DropdownMenuItem( + value: g.id, + child: Text(g.name), + ), + ), + ], + onChanged: (val) => setState(() => _selectedGroupId = val), + ), + + // Formula dropdown + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _selectedFormula, + decoration: const InputDecoration( + labelText: 'Auto-calculate from formula (optional)', + helperText: 'values are computed automatically, no entry needed.', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('- Manual entry -'), + ), + ..._formulaLabels.entries.map( + (e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + ), + ), + ], + onChanged: (val) => setState(() => _selectedFormula = val), + ), + + const SizedBox(height: 16), ElevatedButton( - child: Text(AppLocalizations.of(context).save), + child: Text(i18n.save), onPressed: () async { // Validate and save the current values to the weightEntry - final isValid = _form.currentState!.validate(); + final isValid = _form.currentState?.validate() ?? false; if (!isValid) { return; } _form.currentState!.save(); + setState(() => _isSubmitting = true); + final measurementProvider = Provider.of(context, listen: false); // Save the entry on the server - categoryData['id'] == null - ? await Provider.of( - context, - listen: false, - ).addCategory( - MeasurementCategory( - id: categoryData['id'], - name: categoryData['name'], - unit: categoryData['unit'], - ), - ) - : await Provider.of( - context, - listen: false, - ).editCategory( - categoryData['id'], - categoryData['name'], - categoryData['unit'], - ); - - if (context.mounted) { - Navigator.of(context).pop(); + try { + if (_categoryId == null) { + await measurementProvider.addCategory( + MeasurementCategory( + id: null, + name: nameController.text.trim(), + unit: unitController.text.trim(), + groupId: _selectedGroupId, + formula: _selectedFormula, + ), + ); + } else { + await measurementProvider.editCategory( + _categoryId!, + nameController.text.trim(), + unitController.text.trim(), + _selectedGroupId, + _selectedFormula, + clearGroup: _selectedGroupId == null, + clearFormula: _selectedFormula == null, + ); + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + setState(() => _isSubmitting = false); } }, ), @@ -131,41 +214,62 @@ class MeasurementCategoryForm extends StatelessWidget { } } -class MeasurementEntryForm extends StatelessWidget { +class MeasurementEntryForm extends StatefulWidget { + final int categoryId; + final MeasurementEntry? entry; + + const MeasurementEntryForm({ + super.key, + required this.categoryId, + this.entry, + }); + + @override + State createState() => _MeasurementEntryFormState(); +} + +class _MeasurementEntryFormState extends State { final _form = GlobalKey(); - final int _categoryId; - final _valueController = TextEditingController(); - final _dateController = TextEditingController(text: ''); - final _timeController = TextEditingController(text: ''); - final _notesController = TextEditingController(); - - late final Map _entryData; - - MeasurementEntryForm(this._categoryId, [MeasurementEntry? entry]) { - _entryData = { - 'id': null, - 'category': _categoryId, - 'date': DateTime.now(), - 'value': '', - 'notes': '', - }; - - if (entry != null) { - _entryData['id'] = entry.id; - _entryData['category'] = entry.category; - _entryData['value'] = entry.value; - _entryData['date'] = entry.date; - _entryData['notes'] = entry.notes; - } + late final int _categoryId; + late final TextEditingController _valueController; + late final TextEditingController _dateController; + late final TextEditingController _timeController; + late final TextEditingController _notesController; - _valueController.text = ''; - _notesController.text = _entryData['notes']!; + late DateTime _selectedDateTime; + late num _selectedValue; + late String _selectedNotes; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _categoryId = widget.categoryId; + _selectedDateTime = widget.entry?.date ?? DateTime.now(); + _selectedValue = widget.entry?.value ?? 0; + _selectedNotes = widget.entry?.notes ?? ''; + _dateController = TextEditingController(); + _timeController = TextEditingController(); + _valueController = TextEditingController(); + _notesController = TextEditingController(text: widget.entry?.notes ?? ''); + } + + @override + void dispose() { + _valueController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _notesController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); - final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); + final i18n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toString(); + + final dateFormat = DateFormat.yMd(locale); + final timeFormat = DateFormat.Hm(locale); final measurementProvider = Provider.of(context, listen: false); final measurementCategory = measurementProvider.categories.firstWhere( @@ -173,17 +277,45 @@ class MeasurementEntryForm extends StatelessWidget { ); if (_dateController.text.isEmpty) { - _dateController.text = dateFormat.format(_entryData['date']); + _dateController.text = dateFormat.format(_selectedDateTime); } if (_timeController.text.isEmpty) { - _timeController.text = timeFormat.format(_entryData['date']); + _timeController.text = timeFormat.format(_selectedDateTime); } - final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + final numberFormat = NumberFormat.decimalPattern(locale); // If the value is not empty, format it - if (_valueController.text.isEmpty && _entryData['value'] != null && _entryData['value'] != '') { - _valueController.text = numberFormat.format(_entryData['value']); + if (_valueController.text.isEmpty && widget.entry?.value != null) { + _valueController.text = numberFormat.format(widget.entry!.value); + } + + if (measurementCategory.isDynamic) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.auto_graph, size: 48, color: Colors.blue), + const SizedBox(height: 12), + Text( + 'This measurement is calculated automatically.', // TODO: i18n + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Formula: ${measurementCategory.formula ?? ''}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + 'To see computed values, open the category detail page.', + textAlign: TextAlign.center, + ), + ], + ), + ); } return Form( @@ -193,7 +325,7 @@ class MeasurementEntryForm extends StatelessWidget { // Date TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).date, + labelText: i18n.date, suffixIcon: const Icon( Icons.calendar_today, key: Key('calendarIcon'), @@ -209,7 +341,7 @@ class MeasurementEntryForm extends StatelessWidget { // Show Date Picker Here final pickedDate = await showDatePicker( context: context, - initialDate: _entryData['date'], + initialDate: _selectedDateTime, firstDate: DateTime(DateTime.now().year - 10), lastDate: DateTime.now(), ); @@ -220,15 +352,15 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final date = dateFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = (_selectedDateTime as DateTime).copyWith( year: date.year, month: date.month, day: date.day, ); }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.isEmpty) { + return i18n.enterValue; } return null; }, @@ -237,7 +369,7 @@ class MeasurementEntryForm extends StatelessWidget { // Time TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).time, + labelText: i18n.time, suffixIcon: const Icon( Icons.access_time_outlined, key: Key('clockIcon'), @@ -248,7 +380,7 @@ class MeasurementEntryForm extends StatelessWidget { onTap: () async { final pickedTime = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_entryData['date']), + initialTime: TimeOfDay.fromDateTime(_selectedDateTime), ); if (pickedTime != null) { @@ -266,7 +398,7 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final time = timeFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = (_selectedDateTime as DateTime).copyWith( hour: time.hour, minute: time.minute, second: time.second, @@ -277,39 +409,39 @@ class MeasurementEntryForm extends StatelessWidget { // Value TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).value, + labelText: i18n.value, suffixIcon: Text(measurementCategory.unit), suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0), ), controller: _valueController, keyboardType: textInputTypeDecimal, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.isEmpty) { + return i18n.enterValue; } try { numberFormat.parse(value); } catch (error) { - return AppLocalizations.of(context).enterValidNumber; + return i18n.enterValidNumber; } return null; }, onSaved: (newValue) { - _entryData['value'] = numberFormat.parse(newValue!); + _selectedValue = numberFormat.parse(newValue!); }, ), // Notes TextFormField( - decoration: InputDecoration(labelText: AppLocalizations.of(context).notes), + decoration: InputDecoration(labelText: i18n.notes), controller: _notesController, onSaved: (newValue) { - _entryData['notes'] = newValue; + _selectedNotes = newValue!; }, validator: (value) { const minLength = 0; const maxLength = 100; if (value!.isNotEmpty && (value.length < minLength || value.length > maxLength)) { - return AppLocalizations.of(context).enterCharacters( + return i18n.enterCharacters( minLength.toString(), maxLength.toString(), ); @@ -319,44 +451,48 @@ class MeasurementEntryForm extends StatelessWidget { ), ElevatedButton( - child: Text(AppLocalizations.of(context).save), - onPressed: () async { - // Validate and save the current values to the weightEntry - final isValid = _form.currentState!.validate(); - if (!isValid) { - return; - } - _form.currentState!.save(); + onPressed: _isSubmitting + ? null + : () async { + // Validate and save the current values to the weightEntry + final isValid = _form.currentState!.validate(); + if (!isValid) { + return; + } + setState(() => _isSubmitting = true); + _form.currentState!.save(); - // Save the entry on the server - _entryData['id'] == null - ? await Provider.of( - context, - listen: false, - ).addEntry( - MeasurementEntry( - id: _entryData['id'], - category: _entryData['category'], - date: _entryData['date'], - value: _entryData['value'], - notes: _entryData['notes'], - ), - ) - : await Provider.of( - context, - listen: false, - ).editEntry( - _entryData['id'], - _entryData['category'], - _entryData['value'], - _entryData['notes'], - _entryData['date'], - ); - - if (context.mounted) { - Navigator.of(context).pop(); - } - }, + final provider = Provider.of(context, listen: false); + try { + // Save the entry on the server + widget.entry == null + ? await provider.addEntry( + MeasurementEntry( + id: null, + category: _categoryId, + date: _selectedDateTime, + value: _selectedValue, + notes: _selectedNotes, + ), + ) + : await provider.editEntry( + widget.entry!.id!, + _categoryId, + _selectedValue, + _selectedNotes, + _selectedDateTime, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + debugPrint('Save failed: $e'); + } finally { + setState(() => _isSubmitting = false); + } + }, + child: Text(i18n.save), ), ], ), From 38c0a168b3a6350a3860a54eb8f19912b88ce22e Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Tue, 19 May 2026 19:06:47 +0545 Subject: [PATCH 09/22] adjust code files as per backend measurement entryurl endpoint --- .../measurements/measurement_category.dart | 22 +- .../measurements/measurement_category.g.dart | 7 +- .../measurements/measurement_group.dart | 8 +- .../measurements/measurement_group.g.dart | 15 +- .../measurements/mock_measurement_data.dart | 238 ++++++++++++++++++ lib/providers/measurement.dart | 53 ++-- .../measurement_categories_screen.dart | 76 +++--- lib/screens/measurement_entries_screen.dart | 69 +++-- .../dashboard/widgets/measurements.dart | 186 +++++++------- lib/widgets/measurements/categories.dart | 29 ++- lib/widgets/measurements/entries.dart | 82 +++--- lib/widgets/measurements/forms.dart | 50 +--- 12 files changed, 531 insertions(+), 304 deletions(-) create mode 100644 lib/models/measurements/mock_measurement_data.dart diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index eb268b0e5..46692f93e 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -23,11 +23,11 @@ class MeasurementCategory extends Equatable { @JsonKey(name: 'group_detail', includeToJson: false) final MeasurementGroup? groupDetail; - @JsonKey(name: 'formula', includeToJson: true) + @JsonKey(name: 'dynamic_type', includeToJson: true) final String? formula; - @JsonKey(name: 'is_dynamic', includeToJson: false, defaultValue: false) - final bool isDynamic; + @JsonKey(name: 'dynamic_params', includeToJson: true, defaultValue: {}) + final Map dynamicParams; @JsonKey(defaultValue: [], toJson: _nullValue) final List entries; @@ -39,28 +39,32 @@ class MeasurementCategory extends Equatable { this.groupId, this.groupDetail, this.formula, - this.isDynamic = false, + this.dynamicParams = const {}, this.entries = const [], }); + bool get isDynamic => formula != null && formula != 'NONE'; + MeasurementCategory copyWith({ int? id, String? name, String? unit, int? groupId, + bool clearGroup = false, MeasurementGroup? groupDetail, + bool clearGroupDetail = false, String? formula, - bool? isDynamic, + Map? dynamicParams, List? entries, }) { return MeasurementCategory( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, - groupId: groupId ?? this.groupId, - groupDetail: groupDetail ?? this.groupDetail, + groupId: clearGroup ? null : (groupId ?? this.groupId), + groupDetail: clearGroupDetail ? null : (groupDetail ?? this.groupDetail), formula: formula ?? this.formula, - isDynamic: isDynamic ?? this.isDynamic, + dynamicParams: dynamicParams ?? this.dynamicParams, entries: entries ?? this.entries, ); } @@ -79,7 +83,7 @@ class MeasurementCategory extends Equatable { Map toJson() => _$MeasurementCategoryToJson(this); @override - List get props => [id, name, unit, groupId, formula, isDynamic, entries]; + List get props => [id, name, unit, groupId, formula, dynamicParams, entries]; // Helper function which makes the entries list of the toJson output null, as it isn't needed static Null _nullValue(List _) => null; diff --git a/lib/models/measurements/measurement_category.g.dart b/lib/models/measurements/measurement_category.g.dart index 8ead8e403..e3b6b1df5 100644 --- a/lib/models/measurements/measurement_category.g.dart +++ b/lib/models/measurements/measurement_category.g.dart @@ -18,8 +18,8 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map json) { : MeasurementGroup.fromJson( json['group_detail'] as Map, ), - formula: json['formula'] as String?, - isDynamic: json['is_dynamic'] as bool? ?? false, + formula: json['dynamic_type'] as String?, + dynamicParams: json['dynamic_params'] as Map? ?? {}, entries: (json['entries'] as List?) ?.map((e) => MeasurementEntry.fromJson(e as Map)) @@ -35,6 +35,7 @@ Map _$MeasurementCategoryToJson( 'name': instance.name, 'unit': instance.unit, 'group': instance.groupId, - 'formula': instance.formula, + 'dynamic_type': instance.formula, + 'dynamic_params': instance.dynamicParams, 'entries': MeasurementCategory._nullValue(instance.entries), }; diff --git a/lib/models/measurements/measurement_group.dart b/lib/models/measurements/measurement_group.dart index 93fd211e5..6dec86342 100644 --- a/lib/models/measurements/measurement_group.dart +++ b/lib/models/measurements/measurement_group.dart @@ -1,4 +1,3 @@ - import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -22,16 +21,15 @@ class MeasurementGroup extends Equatable { const MeasurementGroup({ required this.id, - required this.uuid, + this.uuid = '', required this.name, this.description = '', }); - factory MeasurementGroup.fromJson(Map json) => - _$MeasurementGroupFromJson(json); + factory MeasurementGroup.fromJson(Map json) => _$MeasurementGroupFromJson(json); Map toJson() => _$MeasurementGroupToJson(this); @override List get props => [id, uuid, name, description]; -} \ No newline at end of file +} diff --git a/lib/models/measurements/measurement_group.g.dart b/lib/models/measurements/measurement_group.g.dart index 51fee1ae7..59e4cfd1b 100644 --- a/lib/models/measurements/measurement_group.g.dart +++ b/lib/models/measurements/measurement_group.g.dart @@ -10,16 +10,15 @@ MeasurementGroup _$MeasurementGroupFromJson(Map json) { $checkKeys(json, requiredKeys: const ['id', 'uuid', 'name']); return MeasurementGroup( id: (json['id'] as num).toInt(), - uuid: json['uuid'] as String, + uuid: json['uuid'] as String? ?? '', name: json['name'] as String, description: json['description'] as String? ?? '', ); } -Map _$MeasurementGroupToJson(MeasurementGroup instance) => - { - 'id': instance.id, - 'uuid': instance.uuid, - 'name': instance.name, - 'description': instance.description, - }; +Map _$MeasurementGroupToJson(MeasurementGroup instance) => { + 'id': instance.id, + 'uuid': instance.uuid, + 'name': instance.name, + 'description': instance.description, +}; diff --git a/lib/models/measurements/mock_measurement_data.dart b/lib/models/measurements/mock_measurement_data.dart new file mode 100644 index 000000000..dcb9dac5a --- /dev/null +++ b/lib/models/measurements/mock_measurement_data.dart @@ -0,0 +1,238 @@ +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; + +// ignore: avoid_classes_with_only_static_members +class MeasurementMockData { + static List get dummyGroups => [ + const MeasurementGroup( + id: 9901, + name: 'Body Composition (Mock)', + ), + const MeasurementGroup( + id: 9902, + name: 'Cardiovascular Metrics (Mock)', + ), + const MeasurementGroup( + id: 9903, + name: 'Strength Performance (Mock)', + ), + ]; + + static List get dummyCategories { + final now = DateTime.now(); + + return [ + MeasurementCategory( + id: 8801, + name: 'Weight (Mock)', + unit: 'kg', + groupId: 9901, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7701, + category: 8801, + date: now.subtract(const Duration(days: 14)), + value: 89.5, + notes: 'Initial mock reading.', + ), + MeasurementEntry( + id: 7702, + category: 8801, + date: now.subtract(const Duration(days: 7)), + value: 88.8, + notes: 'Morning weight after fasting.', + ), + MeasurementEntry( + id: 7703, + category: 8801, + date: now.subtract(const Duration(days: 1)), + value: 88.2, + notes: 'Consistent drop tracked.', + ), + MeasurementEntry( + id: 7726, + category: 8801, + date: now.subtract(const Duration(days: 32)), + value: 88.4, + notes: 'Auto-generated mock reading 5.', + ), + MeasurementEntry( + id: 7725, + category: 8801, + date: now.subtract(const Duration(days: 30)), + value: 87.9, + notes: 'Auto-generated mock reading 6.', + ), + MeasurementEntry( + id: 7724, + category: 8801, + date: now.subtract(const Duration(days: 28)), + value: 87.5, + notes: 'Auto-generated mock reading 7.', + ), + MeasurementEntry( + id: 7723, + category: 8801, + date: now.subtract(const Duration(days: 26)), + value: 87.3, + notes: 'Auto-generated mock reading 8.', + ), + MeasurementEntry( + id: 7722, + category: 8801, + date: now.subtract(const Duration(days: 24)), + value: 87.1, + notes: 'Auto-generated mock reading 9.', + ), + MeasurementEntry( + id: 7721, + category: 8801, + date: now.subtract(const Duration(days: 22)), + value: 86.6, + notes: 'Auto-generated mock reading 10.', + ), + MeasurementEntry( + id: 7720, + category: 8801, + date: now.subtract(const Duration(days: 20)), + value: 86.4, + notes: 'Auto-generated mock reading 11.', + ), + MeasurementEntry( + id: 7719, + category: 8801, + date: now.subtract(const Duration(days: 18)), + value: 86.0, + notes: 'Auto-generated mock reading 12.', + ), + MeasurementEntry( + id: 7718, + category: 8801, + date: now.subtract(const Duration(days: 16)), + value: 85.7, + notes: 'Auto-generated mock reading 13.', + ), + MeasurementEntry( + id: 7717, + category: 8801, + date: now.subtract(const Duration(days: 14)), + value: 85.4, + notes: 'Auto-generated mock reading 14.', + ), + MeasurementEntry( + id: 7716, + category: 8801, + date: now.subtract(const Duration(days: 12)), + value: 85.3, + notes: 'Auto-generated mock reading 15.', + ), + MeasurementEntry( + id: 7715, + category: 8801, + date: now.subtract(const Duration(days: 10)), + value: 84.9, + notes: 'Auto-generated mock reading 16.', + ), + MeasurementEntry( + id: 7714, + category: 8801, + date: now.subtract(const Duration(days: 8)), + value: 84.8, + notes: 'Auto-generated mock reading 17.', + ), + MeasurementEntry( + id: 7713, + category: 8801, + date: now.subtract(const Duration(days: 6)), + value: 84.7, + notes: 'Auto-generated mock reading 18.', + ), + MeasurementEntry( + id: 7712, + category: 8801, + date: now.subtract(const Duration(days: 4)), + value: 84.2, + notes: 'Auto-generated mock reading 19.', + ), + MeasurementEntry( + id: 7711, + category: 8801, + date: now.subtract(const Duration(days: 2)), + value: 83.9, + notes: 'Auto-generated mock reading 20.', + ), + ], + ), + + // BMI + MeasurementCategory( + id: 8802, + name: 'Body Mass Index (Mock Formula)', + unit: 'index', + groupId: 9901, + formula: 'BMI', + entries: [ + MeasurementEntry( + id: 7704, + category: 8802, + date: now.subtract(const Duration(days: 14)), + value: 24.5, + notes: 'Computed dynamically from height/weight metrics.', + ), + MeasurementEntry( + id: 7705, + category: 8802, + date: now.subtract(const Duration(days: 7)), + value: 24.2, + notes: 'Auto-updated baseline entry.', + ), + ], + ), + + // Chest Circumference has No Assigned Group Link + MeasurementCategory( + id: 8803, + name: 'Chest Circumference (Mock)', + unit: 'cm', + groupId: null, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7706, + category: 8803, + date: now.subtract(const Duration(days: 30)), + value: 104.0, + notes: 'Baseline measurement.', + ), + MeasurementEntry( + id: 7707, + category: 8803, + date: now, + value: 106.5, + notes: 'Hypertrophy progress visible.', + ), + ], + ), + + // Blood Pressure + MeasurementCategory( + id: 8804, + name: 'Systolic Blood Pressure (Mock)', + unit: 'mmHg', + groupId: 9902, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7708, + category: 8804, + date: now.subtract(const Duration(days: 3)), + value: 120.0, + notes: 'Normal home rest conditions.', + ), + ], + ), + ]; + } +} diff --git a/lib/providers/measurement.dart b/lib/providers/measurement.dart index 213967177..a144b787b 100644 --- a/lib/providers/measurement.dart +++ b/lib/providers/measurement.dart @@ -24,6 +24,7 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; import 'package:wger/models/measurements/measurement_group.dart'; +import 'package:wger/models/measurements/mock_measurement_data.dart'; import 'package:wger/providers/base_provider.dart'; class MeasurementProvider with ChangeNotifier { @@ -32,13 +33,16 @@ class MeasurementProvider with ChangeNotifier { static const _categoryUrl = 'measurement-category'; static const _entryUrl = 'measurement'; static const _groupUrl = 'measurement-group'; - static const _dynamicValuesAction = 'dynamic-values'; final WgerBaseProvider baseProvider; List _categories = []; List _groups = []; - MeasurementProvider(this.baseProvider); + MeasurementProvider(this.baseProvider) { + // TODO: REMOVE before merge — loads mock data for UI development + _groups = MeasurementMockData.dummyGroups; + _categories = MeasurementMockData.dummyCategories; + } List get categories => _categories; List get groups => _groups; @@ -75,11 +79,6 @@ class MeasurementProvider with ChangeNotifier { Future fetchAndSetCategoryEntries(int id) async { final category = findCategoryById(id); - if (category.isDynamic) { - await fetchDynamicCategoryEntries(id); - return; - } - final categoryIndex = _categories.indexOf(category); // Process the response @@ -98,34 +97,6 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } - /// Fetches computed values for a dynamic (formula-based) category - /// and sets the measurement entries - Future fetchDynamicCategoryEntries(int categoryId) async { - final category = findCategoryById(categoryId); - if (!category.isDynamic) return; - - final categoryIndex = _categories.indexOf(category); - - final requestUrl = baseProvider.makeUrl( - '$_categoryUrl/$categoryId/$_dynamicValuesAction', - ); - - // The dynamic-values endpoint returns a plain list, not paginated. - final dynamic rawData = await baseProvider.fetch(requestUrl); - final List data = rawData is List ? rawData : (rawData['results'] ?? []); - - final List computedEntries = data - .map((e) => MeasurementEntry.fromJson(e as Map)) - .toList(); - - final MeasurementCategory editedCategory = category.copyWith( - entries: computedEntries, - ); - _categories.removeAt(categoryIndex); - _categories.insert(categoryIndex, editedCategory); - notifyListeners(); - } - /// Fetches and sets the measurement categories and their entries Future fetchAndSetAllCategoriesAndEntries() async { _logger.info('Fetching all measurement categories and entries'); @@ -175,8 +146,13 @@ class MeasurementProvider with ChangeNotifier { }) async { final MeasurementCategory oldCategory = findCategoryById(id); final int categoryIndex = _categories.indexOf(oldCategory); - final MeasurementCategory tempNewCategory = oldCategory.copyWith(name: newName, unit: newUnit); - + final MeasurementCategory tempNewCategory = oldCategory.copyWith( + name: newName, + unit: newUnit, + groupId: newGroupId, + clearGroup: clearGroup, + formula: newFormula, + ); final Map response = await baseProvider.patch( tempNewCategory.toJson(), baseProvider.makeUrl(_categoryUrl, id: id), @@ -288,8 +264,6 @@ class MeasurementProvider with ChangeNotifier { } /// Edits a measurement entry - /// Currently there isn't any fallback if the call to the api is unsuccessful, as - /// WgerBaseProvider.patch only returns the response body and not the whole response Future editEntry( int id, int categoryId, @@ -298,6 +272,7 @@ class MeasurementProvider with ChangeNotifier { DateTime? newDate, ) async { final MeasurementCategory category = findCategoryById(categoryId); + final MeasurementEntry oldEntry = category.findEntryById(id); final int entryIndex = category.entries.indexOf(oldEntry); final MeasurementEntry tempNewEntry = oldEntry.copyWith( diff --git a/lib/screens/measurement_categories_screen.dart b/lib/screens/measurement_categories_screen.dart index 5aa28a2a2..6934344c3 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -26,7 +26,6 @@ import 'package:wger/widgets/measurements/categories.dart'; import 'package:wger/widgets/measurements/forms.dart'; class MeasurementCategoriesScreen extends StatefulWidget { - const MeasurementCategoriesScreen(); static const routeName = '/measurement-categories'; @@ -37,7 +36,6 @@ class MeasurementCategoriesScreen extends StatefulWidget { class _MeasurementCategoriesScreenState extends State { int? _selectedGroupId; - @override Widget build(BuildContext context) { return Scaffold( @@ -50,7 +48,7 @@ class _MeasurementCategoriesScreenState extends State( builder: (context, provider, child) { - - final groups = provider.groups; - if (groups.isEmpty) return const SizedBox.shrink(); + final groups = provider.groups; + + return Column( + children: [ + // Group filter chips + if (groups.isNotEmpty) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + FilterChip( + label: const Text('All'), + selected: _selectedGroupId == null, + onSelected: (_) => setState(() => _selectedGroupId = null), + ), + const SizedBox(width: 6), + ...groups.map( + (group) => Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(group.name), + selected: _selectedGroupId == group.id, + onSelected: (_) => setState( + () => _selectedGroupId = _selectedGroupId == group.id + ? null + : group.id, + ), + ), + ), + ), + ], + ), + ), - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - // clears group filter - FilterChip( - label: const Text('All'), - selected: _selectedGroupId == null, - onSelected: (_) => setState(() => _selectedGroupId = null), - ), - const SizedBox(width: 6), - // One chip per group - ...groups.map( - (group) => Padding( - padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(group.name), - selected: _selectedGroupId == group.id, - onSelected: (_) => setState( - () => _selectedGroupId = - _selectedGroupId == group.id ? null : group.id, + Expanded( + child: CategoriesList(_selectedGroupId), ), - ), - ), - ), - CategoriesList(_selectedGroupId), - ], - ), - ); - } + ], + ); + }, ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart index b8a5728af..43623f7bd 100644 --- a/lib/screens/measurement_entries_screen.dart +++ b/lib/screens/measurement_entries_screen.dart @@ -55,28 +55,54 @@ class MeasurementEntriesScreen extends StatelessWidget { } if (category.isDynamic) { - return Container( - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.auto_graph, color: Colors.blue, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Values for this category are calculated automatically ' - 'using the "${category.formula}" formula. ' - 'You cannot add entries manually.', - style: const TextStyle(fontSize: 13), - ), + return Scaffold( + appBar: AppBar( + title: Text(category.name), + actions: [ + Chip( + avatar: const Icon(Icons.auto_awesome, size: 14), + label: const Text('Auto-calculated'), + backgroundColor: Colors.blue.withValues(alpha: 0.15), ), + const SizedBox(width: 8), ], ), + body: WidescreenWrapper( + child: SingleChildScrollView( + child: Column( + children: [ + // Info banner + Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.auto_graph, color: Colors.blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Values for this category are calculated automatically ' + 'using the "${category.formula == 'NONE' || category.formula == null ? 'formula' : category.formula!.toLowerCase()}" formula. ' + 'You cannot add entries manually.', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ), + // Show the computed entries read-only + Consumer( + builder: (context, provider, child) => EntriesList(category!), + ), + ], + ), + ), + ), ); } @@ -94,7 +120,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).edit, - MeasurementCategoryForm(), + MeasurementCategoryForm(category: category), ), ); break; @@ -128,9 +154,8 @@ class MeasurementEntriesScreen extends StatelessWidget { // Close the popup Navigator.of(contextDialog).pop(); - Navigator.of(context).pop(); // Exit detail screen + Navigator.of(context).pop(); - // and inform the user ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/widgets/dashboard/widgets/measurements.dart b/lib/widgets/dashboard/widgets/measurements.dart index 498028aae..a9794afe6 100644 --- a/lib/widgets/dashboard/widgets/measurements.dart +++ b/lib/widgets/dashboard/widgets/measurements.dart @@ -40,105 +40,107 @@ class _DashboardMeasurementWidgetState extends State @override Widget build(BuildContext context) { - final provider = Provider.of(context, listen: false); + // final provider = Provider.of(context, listen: false); - final items = provider.categories - .map((item) => CategoriesCard(item, elevation: 0)) - .toList(); - if (items.isNotEmpty) { - items.add( - NothingFound( - AppLocalizations.of(context).moreMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ); - } return Consumer( - builder: (context, _, _) => Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).measurements, - style: Theme.of(context).textTheme.headlineSmall, - ), - leading: FaIcon( - FontAwesomeIcons.chartLine, - color: Theme.of(context).textTheme.headlineSmall!.color, - ), - // TODO: this icon feels out of place and inconsistent with all - // other dashboard widgets. - // maybe we should just add a "Go to all" at the bottom of the widget - trailing: IconButton( - icon: const Icon(Icons.arrow_forward), - onPressed: () => Navigator.pushNamed( - context, - MeasurementCategoriesScreen.routeName, + builder: (context, provider, _) { + final items = provider.categories + .map((item) => CategoriesCard(item, elevation: 0)) + .toList(); + if (items.isNotEmpty) { + items.add( + NothingFound( + AppLocalizations.of(context).moreMeasurementEntries, + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + ), + ); + } + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + AppLocalizations.of(context).measurements, + style: Theme.of(context).textTheme.headlineSmall, + ), + leading: FaIcon( + FontAwesomeIcons.chartLine, + color: Theme.of(context).textTheme.headlineSmall!.color, + ), + // TODO: this icon feels out of place and inconsistent with all + // other dashboard widgets. + // maybe we should just add a "Go to all" at the bottom of the widget + trailing: IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: () => Navigator.pushNamed( + context, + MeasurementCategoriesScreen.routeName, + ), ), ), - ), - Column( - children: [ - if (items.isNotEmpty) - Column( - children: [ - CarouselSlider( - items: items, - carouselController: _controller, - options: CarouselOptions( - autoPlay: false, - enlargeCenterPage: false, - viewportFraction: 1, - enableInfiniteScroll: false, - aspectRatio: 1.1, - onPageChanged: (index, reason) { - setState(() { - _current = index; - }); - }, + Column( + children: [ + if (items.isNotEmpty) + Column( + children: [ + CarouselSlider( + items: items, + carouselController: _controller, + options: CarouselOptions( + autoPlay: false, + enlargeCenterPage: false, + viewportFraction: 1, + enableInfiniteScroll: false, + aspectRatio: 1.1, + onPageChanged: (index, reason) { + setState(() { + _current = index; + }); + }, + ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: items.asMap().entries.map((entry) { - return GestureDetector( - onTap: () => _controller.animateToPage(entry.key), - child: Container( - width: 12.0, - height: 12.0, - margin: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 4.0, - ), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).textTheme.headlineSmall!.color! - .withValues( - alpha: _current == entry.key ? 0.9 : 0.4, - ), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: items.asMap().entries.map((entry) { + return GestureDetector( + onTap: () => _controller.animateToPage(entry.key), + child: Container( + width: 12.0, + height: 12.0, + margin: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 4.0, + ), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).textTheme.headlineSmall!.color! + .withValues( + alpha: _current == entry.key ? 0.9 : 0.4, + ), + ), ), - ), - ); - }).toList(), + ); + }).toList(), + ), ), - ), - ], - ) - else - NothingFound( - AppLocalizations.of(context).noMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ], - ), - ], - ), - ), + ], + ) + else + NothingFound( + AppLocalizations.of(context).noMeasurementEntries, + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + ), + ], + ), + ], + ), + ); + }, ); } } diff --git a/lib/widgets/measurements/categories.dart b/lib/widgets/measurements/categories.dart index a00905175..a8ead1a68 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -23,24 +23,31 @@ import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; class CategoriesList extends StatelessWidget { - final int? _selectedGrouptID; - const CategoriesList(this._selectedGrouptID); + final int? _selectedGroupID; + const CategoriesList([this._selectedGroupID]); @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); - final categories = provider.categories.where((cat) { - if (_selectedGrouptID == null) return true; - return cat.groupId == _selectedGrouptID; - }).toList(); + // Filter by group if a chip is selected + final visibleCategories = _selectedGroupID == null + ? provider.categories + : provider.categories.where((c) => c.groupId == _selectedGroupID).toList(); return RefreshIndicator( onRefresh: () => provider.fetchAndSetAllCategoriesAndEntries(), - child: ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: categories.length, - itemBuilder: (context, index) => CategoriesCard(categories[index]), - ), + child: visibleCategories.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('No categories found.'), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(10.0), + itemCount: visibleCategories.length, + itemBuilder: (context, index) => CategoriesCard(visibleCategories[index]), + ), ); } } diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 6985a1bc4..cb82b6d69 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -69,48 +69,54 @@ class EntriesList extends StatelessWidget { child: ListTile( title: Text('${numberFormat.format(currentEntry.value)} ${_category.unit}'), subtitle: Text(datetimeFormat.format(currentEntry.date)), - trailing: PopupMenuButton( - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - child: Text(AppLocalizations.of(context).edit), - onTap: () => Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - MeasurementEntryForm( - categoryId: currentEntry.category, - entry: currentEntry, - ), - ), - ), - ), - PopupMenuItem( - child: Text(AppLocalizations.of(context).delete), - onTap: () async { - // Delete entry from DB - await provider.deleteEntry( - currentEntry.id!, - currentEntry.category, - ); - - // and inform the user - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, + // Hide edit/delete entry for Dynamic category + trailing: _category.isDynamic + ? const Tooltip( + message: 'Auto-calculated — cannot be edited or deleted', + child: Icon(Icons.auto_graph, size: 18, color: Colors.blue), + ) + : PopupMenuButton( + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + child: Text(AppLocalizations.of(context).edit), + onTap: () => Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).edit, + MeasurementEntryForm( + categoryId: currentEntry.category, + entry: currentEntry, + ), ), ), - ); - } + ), + PopupMenuItem( + child: Text(AppLocalizations.of(context).delete), + onTap: () async { + // Delete entry from DB + await provider.deleteEntry( + currentEntry.id!, + currentEntry.category, + ); + + // and inform the user + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + ), + ); + } + }, + ), + ]; }, ), - ]; - }, - ), ), ); }, diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index b69f08015..ff217e698 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -39,11 +39,6 @@ class MeasurementCategoryForm extends StatefulWidget { } class _MeasurementCategoryFormState extends State { - final List _dummyGroups = [ - const MeasurementGroup(uuid: '1', id: 1, name: 'Dummy: Bodyweight'), - const MeasurementGroup(uuid: '2', id: 2, name: 'Dummy: Strength'), - ]; - final _form = GlobalKey(); late final TextEditingController nameController; late final TextEditingController unitController; @@ -55,9 +50,9 @@ class _MeasurementCategoryFormState extends State { bool _isSubmitting = false; static const Map _formulaLabels = { - 'bmi': 'Body Mass Index (BMI)', - 'lbm': 'Lean Body Mass', - '1rm_epley': '1RM — Epley formula', + 'BMI': 'Body Mass Index (BMI)', + // 'LBM': 'Lean Body Mass', + // '1RM_EPLEY': '1RM — Epley formula', }; @override @@ -69,7 +64,7 @@ class _MeasurementCategoryFormState extends State { unitController = TextEditingController(text: cat?.unit ?? ''); _selectedGroupId = cat?.groupId; - _selectedFormula = cat?.formula; + _selectedFormula = (cat?.formula == null || cat?.formula == 'NONE') ? null : cat?.formula; } @override @@ -177,6 +172,7 @@ class _MeasurementCategoryFormState extends State { final measurementProvider = Provider.of(context, listen: false); // Save the entry on the server + final formulaToSave = _selectedFormula ?? 'NONE'; try { if (_categoryId == null) { await measurementProvider.addCategory( @@ -185,7 +181,7 @@ class _MeasurementCategoryFormState extends State { name: nameController.text.trim(), unit: unitController.text.trim(), groupId: _selectedGroupId, - formula: _selectedFormula, + formula: formulaToSave, ), ); } else { @@ -194,7 +190,7 @@ class _MeasurementCategoryFormState extends State { nameController.text.trim(), unitController.text.trim(), _selectedGroupId, - _selectedFormula, + formulaToSave, clearGroup: _selectedGroupId == null, clearFormula: _selectedFormula == null, ); @@ -290,34 +286,6 @@ class _MeasurementEntryFormState extends State { _valueController.text = numberFormat.format(widget.entry!.value); } - if (measurementCategory.isDynamic) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.auto_graph, size: 48, color: Colors.blue), - const SizedBox(height: 12), - Text( - 'This measurement is calculated automatically.', // TODO: i18n - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - 'Formula: ${measurementCategory.formula ?? ''}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 8), - Text( - 'To see computed values, open the category detail page.', - textAlign: TextAlign.center, - ), - ], - ), - ); - } - return Form( key: _form, child: Column( @@ -352,7 +320,7 @@ class _MeasurementEntryFormState extends State { }, onSaved: (newValue) { final date = dateFormat.parse(newValue!); - _selectedDateTime = (_selectedDateTime as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.copyWith( year: date.year, month: date.month, day: date.day, @@ -398,7 +366,7 @@ class _MeasurementEntryFormState extends State { }, onSaved: (newValue) { final time = timeFormat.parse(newValue!); - _selectedDateTime = (_selectedDateTime as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.copyWith( hour: time.hour, minute: time.minute, second: time.second, From 18b745f3654f26f1b71d14f85e05077d82926ffb Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 2 Jul 2026 21:45:53 +0200 Subject: [PATCH 10/22] Start reworking the sync logic We don't just want to sync the weight entries --- android/app/src/main/AndroidManifest.xml | 5 +- ios/Runner/Runner.entitlements | 4 - lib/core/home_tabs_screen.dart | 10 +- lib/database/powersync/database.g.dart | 168 +++++++++++- .../powersync/tables/measurements.dart | 15 ++ .../account/widgets/settings/health_sync.dart | 13 +- lib/features/health/models/health_metric.dart | 134 ++++++++++ .../health/providers/health_sync.dart | 250 ++++++++++++++++++ .../providers/health_sync.g.dart | 19 +- .../models/measurement_category.dart | 7 + .../models/measurement_category.freezed.dart | 17 +- .../models/measurement_entry.dart | 14 + .../models/measurement_entry.freezed.dart | 20 +- .../providers/measurement_repository.dart | 3 + .../weight/providers/health_sync.dart | 249 ----------------- lib/l10n/app_en.arb | 4 +- lib/powersync/connector.dart | 5 +- .../account/widgets/settings_test.dart | 2 +- .../health/models/health_metric_test.dart | 79 ++++++ .../providers/health_sync_test.dart | 42 +-- .../measurement_notifier_test.mocks.dart | 10 + ...surement_categories_screen_test.mocks.dart | 10 + ...measurement_entries_screen_test.mocks.dart | 10 + test/powersync/connector_test.dart | 10 + .../screenshots_01_dashboard.mocks.dart | 10 + .../screenshots_04_measurements.mocks.dart | 10 + 26 files changed, 787 insertions(+), 333 deletions(-) create mode 100644 lib/features/health/models/health_metric.dart create mode 100644 lib/features/health/providers/health_sync.dart rename lib/features/{weight => health}/providers/health_sync.g.dart (56%) delete mode 100644 lib/features/weight/providers/health_sync.dart create mode 100644 test/features/health/models/health_metric_test.dart rename test/features/{weight => health}/providers/health_sync_test.dart (50%) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1b01bfd55..2016766b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,8 +9,9 @@ - - + + + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 57cd45923..c77f75e0e 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -6,9 +6,5 @@ development com.apple.developer.healthkit - com.apple.developer.healthkit.access - - health-records - diff --git a/lib/core/home_tabs_screen.dart b/lib/core/home_tabs_screen.dart index 5f17f24dd..3ba7f6498 100644 --- a/lib/core/home_tabs_screen.dart +++ b/lib/core/home_tabs_screen.dart @@ -21,12 +21,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:wger/core/dashboard.dart'; import 'package:wger/core/material.dart'; -import 'package:wger/features/account/providers/user_profile_notifier.dart'; import 'package:wger/features/gallery/screens/gallery_screen.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; import 'package:wger/features/nutrition/screens/nutritional_plans_screen.dart'; import 'package:wger/features/routines/screens/routine_list_screen.dart'; -import 'package:wger/features/weight/providers/body_weight_notifier.dart'; -import 'package:wger/features/weight/providers/health_sync.dart'; import 'package:wger/features/weight/screens/weight_screen.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -54,11 +52,7 @@ class _HomeTabsScreenState extends ConsumerState if (!mounted) { return; } - final existingEntries = ref.read(weightEntryProvider).value ?? []; - final isMetric = ref.read(userProfileProvider).value?.isMetric ?? true; - ref - .read(healthSyncProvider.notifier) - .syncOnAppOpen(existingEntries: existingEntries, isMetric: isMetric); + ref.read(healthSyncProvider.notifier).syncOnAppOpen(); }); } diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index e09e93822..b594929e2 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -4880,8 +4880,19 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _metricTypeMeta = const VerificationMeta( + 'metricType', + ); @override - List get $columns => [id, name, unit]; + late final GeneratedColumn metricType = GeneratedColumn( + 'metric_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, name, unit, metricType]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -4913,6 +4924,12 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable } else if (isInserting) { context.missing(_unitMeta); } + if (data.containsKey('metric_type')) { + context.handle( + _metricTypeMeta, + metricType.isAcceptableOrUnknown(data['metric_type']!, _metricTypeMeta), + ); + } return context; } @@ -4934,6 +4951,10 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable DriftSqlType.string, data['${effectivePrefix}unit'], )!, + metricType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metric_type'], + ), ); } @@ -4947,17 +4968,20 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion id; final Value name; final Value unit; + final Value metricType; final Value rowid; const MeasurementCategoryTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), this.unit = const Value.absent(), + this.metricType = const Value.absent(), this.rowid = const Value.absent(), }); MeasurementCategoryTableCompanion.insert({ this.id = const Value.absent(), required String name, required String unit, + this.metricType = const Value.absent(), this.rowid = const Value.absent(), }) : name = Value(name), unit = Value(unit); @@ -4965,12 +4989,14 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion? id, Expression? name, Expression? unit, + Expression? metricType, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, if (unit != null) 'unit': unit, + if (metricType != null) 'metric_type': metricType, if (rowid != null) 'rowid': rowid, }); } @@ -4979,12 +5005,14 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion? id, Value? name, Value? unit, + Value? metricType, Value? rowid, }) { return MeasurementCategoryTableCompanion( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, + metricType: metricType ?? this.metricType, rowid: rowid ?? this.rowid, ); } @@ -5001,6 +5029,9 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion(unit.value); } + if (metricType.present) { + map['metric_type'] = Variable(metricType.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -5013,6 +5044,7 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion get $columns => [id, categoryId, date, value, notes]; + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('manual'), + ); + static const VerificationMeta _externalIdMeta = const VerificationMeta( + 'externalId', + ); + @override + late final GeneratedColumn externalId = GeneratedColumn( + 'external_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + categoryId, + date, + value, + notes, + source, + externalId, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -5116,6 +5177,18 @@ class $MeasurementEntryTableTable extends MeasurementEntryTable } else if (isInserting) { context.missing(_notesMeta); } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } + if (data.containsKey('external_id')) { + context.handle( + _externalIdMeta, + externalId.isAcceptableOrUnknown(data['external_id']!, _externalIdMeta), + ); + } return context; } @@ -5147,6 +5220,14 @@ class $MeasurementEntryTableTable extends MeasurementEntryTable DriftSqlType.string, data['${effectivePrefix}notes'], )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + externalId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}external_id'], + ), ); } @@ -5164,6 +5245,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { final Value date; final Value value; final Value notes; + final Value source; + final Value externalId; final Value rowid; const MeasurementEntryTableCompanion({ this.id = const Value.absent(), @@ -5171,6 +5254,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { this.date = const Value.absent(), this.value = const Value.absent(), this.notes = const Value.absent(), + this.source = const Value.absent(), + this.externalId = const Value.absent(), this.rowid = const Value.absent(), }); MeasurementEntryTableCompanion.insert({ @@ -5179,6 +5264,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { required DateTime date, required double value, required String notes, + this.source = const Value.absent(), + this.externalId = const Value.absent(), this.rowid = const Value.absent(), }) : categoryId = Value(categoryId), date = Value(date), @@ -5190,6 +5277,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { Expression? date, Expression? value, Expression? notes, + Expression? source, + Expression? externalId, Expression? rowid, }) { return RawValuesInsertable({ @@ -5198,6 +5287,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { if (date != null) 'date': date, if (value != null) 'value': value, if (notes != null) 'notes': notes, + if (source != null) 'source': source, + if (externalId != null) 'external_id': externalId, if (rowid != null) 'rowid': rowid, }); } @@ -5208,6 +5299,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { Value? date, Value? value, Value? notes, + Value? source, + Value? externalId, Value? rowid, }) { return MeasurementEntryTableCompanion( @@ -5216,6 +5309,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { date: date ?? this.date, value: value ?? this.value, notes: notes ?? this.notes, + source: source ?? this.source, + externalId: externalId ?? this.externalId, rowid: rowid ?? this.rowid, ); } @@ -5240,6 +5335,12 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { if (notes.present) { map['notes'] = Variable(notes.value); } + if (source.present) { + map['source'] = Variable(source.value); + } + if (externalId.present) { + map['external_id'] = Variable(externalId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -5254,6 +5355,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { ..write('date: $date, ') ..write('value: $value, ') ..write('notes: $notes, ') + ..write('source: $source, ') + ..write('externalId: $externalId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -16368,6 +16471,7 @@ typedef $$MeasurementCategoryTableTableCreateCompanionBuilder = Value id, required String name, required String unit, + Value metricType, Value rowid, }); typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = @@ -16375,6 +16479,7 @@ typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = Value id, Value name, Value unit, + Value metricType, Value rowid, }); @@ -16436,6 +16541,11 @@ class $$MeasurementCategoryTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get metricType => $composableBuilder( + column: $table.metricType, + builder: (column) => ColumnFilters(column), + ); + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableFilterComposer f) f, ) { @@ -16484,6 +16594,11 @@ class $$MeasurementCategoryTableTableOrderingComposer column: $table.unit, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get metricType => $composableBuilder( + column: $table.metricType, + builder: (column) => ColumnOrderings(column), + ); } class $$MeasurementCategoryTableTableAnnotationComposer @@ -16504,6 +16619,11 @@ class $$MeasurementCategoryTableTableAnnotationComposer GeneratedColumn get unit => $composableBuilder(column: $table.unit, builder: (column) => column); + GeneratedColumn get metricType => $composableBuilder( + column: $table.metricType, + builder: (column) => column, + ); + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableAnnotationComposer a) f, ) { @@ -16568,11 +16688,13 @@ class $$MeasurementCategoryTableTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value unit = const Value.absent(), + Value metricType = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion( id: id, name: name, unit: unit, + metricType: metricType, rowid: rowid, ), createCompanionCallback: @@ -16580,11 +16702,13 @@ class $$MeasurementCategoryTableTableTableManager Value id = const Value.absent(), required String name, required String unit, + Value metricType = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion.insert( id: id, name: name, unit: unit, + metricType: metricType, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -16651,6 +16775,8 @@ typedef $$MeasurementEntryTableTableCreateCompanionBuilder = required DateTime date, required double value, required String notes, + Value source, + Value externalId, Value rowid, }); typedef $$MeasurementEntryTableTableUpdateCompanionBuilder = @@ -16660,6 +16786,8 @@ typedef $$MeasurementEntryTableTableUpdateCompanionBuilder = Value date, Value value, Value notes, + Value source, + Value externalId, Value rowid, }); @@ -16722,6 +16850,16 @@ class $$MeasurementEntryTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => ColumnFilters(column), + ); + $$MeasurementCategoryTableTableFilterComposer get categoryId { final $$MeasurementCategoryTableTableFilterComposer composer = $composerBuilder( composer: this, @@ -16774,6 +16912,16 @@ class $$MeasurementEntryTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => ColumnOrderings(column), + ); + $$MeasurementCategoryTableTableOrderingComposer get categoryId { final $$MeasurementCategoryTableTableOrderingComposer composer = $composerBuilder( composer: this, @@ -16818,6 +16966,14 @@ class $$MeasurementEntryTableTableAnnotationComposer GeneratedColumn get notes => $composableBuilder(column: $table.notes, builder: (column) => column); + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + GeneratedColumn get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => column, + ); + $$MeasurementCategoryTableTableAnnotationComposer get categoryId { final $$MeasurementCategoryTableTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -16882,6 +17038,8 @@ class $$MeasurementEntryTableTableTableManager Value date = const Value.absent(), Value value = const Value.absent(), Value notes = const Value.absent(), + Value source = const Value.absent(), + Value externalId = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementEntryTableCompanion( id: id, @@ -16889,6 +17047,8 @@ class $$MeasurementEntryTableTableTableManager date: date, value: value, notes: notes, + source: source, + externalId: externalId, rowid: rowid, ), createCompanionCallback: @@ -16898,6 +17058,8 @@ class $$MeasurementEntryTableTableTableManager required DateTime date, required double value, required String notes, + Value source = const Value.absent(), + Value externalId = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementEntryTableCompanion.insert( id: id, @@ -16905,6 +17067,8 @@ class $$MeasurementEntryTableTableTableManager date: date, value: value, notes: notes, + source: source, + externalId: externalId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index cb9f2d247..743114daa 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -31,6 +31,9 @@ class MeasurementCategoryTable extends Table { TextColumn get name => text()(); TextColumn get unit => text()(); + + /// `null` for plain user-created ("custom") categories. + TextColumn get metricType => text().named('metric_type').nullable()(); } const PowersyncMeasurementCategoryTable = ps.Table( @@ -38,6 +41,7 @@ const PowersyncMeasurementCategoryTable = ps.Table( [ ps.Column.text('name'), ps.Column.text('unit'), + ps.Column.text('metric_type'), ], ); @@ -53,6 +57,14 @@ class MeasurementEntryTable extends Table { DateTimeColumn get date => dateTime().map(const UtcDateTimeConverter())(); RealColumn get value => real()(); TextColumn get notes => text()(); + + /// Where the reading came from: `manual` or a health platform + /// (`apple_health`, `health_connect`). + TextColumn get source => text().withDefault(const Constant('manual'))(); + + /// Platform record UUID, used to deduplicate re-imports. `null` for manual + /// entries. + TextColumn get externalId => text().named('external_id').nullable()(); } const PowersyncMeasurementEntryTable = ps.Table( @@ -62,8 +74,11 @@ const PowersyncMeasurementEntryTable = ps.Table( ps.Column.text('date'), ps.Column.real('value'), ps.Column.text('notes'), + ps.Column.text('source'), + ps.Column.text('external_id'), ], indexes: [ ps.Index('category_idx', [ps.IndexedColumn('category_id')]), + ps.Index('external_id_idx', [ps.IndexedColumn('external_id')]), ], ); diff --git a/lib/features/account/widgets/settings/health_sync.dart b/lib/features/account/widgets/settings/health_sync.dart index 66d5a3d9c..91d3528d2 100644 --- a/lib/features/account/widgets/settings/health_sync.dart +++ b/lib/features/account/widgets/settings/health_sync.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2026 wger Team + * Copyright (c) 2026 - 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 @@ -18,8 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:wger/features/account/providers/user_profile_notifier.dart'; -import 'package:wger/features/weight/providers/health_sync.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; class HealthSyncSettingsTile extends ConsumerStatefulWidget { @@ -66,11 +65,9 @@ class _HealthSyncSettingsTileState extends ConsumerState : (enabled) async { final notifier = ref.read(healthSyncProvider.notifier); if (enabled) { - final profile = ref.read(userProfileProvider).value; - final isMetric = profile?.isMetric ?? true; - final count = await notifier.enableSync(isMetric: isMetric); - // Synced entries land in the local Drift DB and surface through - // the weight stream automatically, so no manual refresh is needed. + final count = await notifier.enableSync(); + // Imported entries land in the local Drift DB and surface through + // the measurement stream automatically, so no manual refresh is needed. if (context.mounted && count > 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(i18n.healthSyncSuccess(count))), diff --git a/lib/features/health/models/health_metric.dart b/lib/features/health/models/health_metric.dart new file mode 100644 index 000000000..f0b224152 --- /dev/null +++ b/lib/features/health/models/health_metric.dart @@ -0,0 +1,134 @@ +/* + * 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:health/health.dart'; + +/// A body metric that can be imported from Apple Health / Health Connect into a +/// measurement category. +/// +/// [metricType] mirrors the server's planned `metric_type` enum and is stored +/// on the created category; it drives the mapping back to a category on the +/// next import. +class HealthMetric { + const HealthMetric({ + required this.metricType, + required this.dataType, + required this.canonicalName, + required this.unit, + required this.toCategoryValue, + this.enabled = false, + this.disabledReason, + }); + + /// Value of the server `metric_type` enum, e.g. `body_fat`. + final String metricType; + + /// Health platform type this metric reads. + final HealthDataType dataType; + + /// Stable, non-localized category name. Used to find-or-create the category, + /// and as the fallback match after a sync round-trip nulls [metricType] + /// (the server does not persist it yet). + final String canonicalName; + + /// Unit the value is stored in on the category. + final String unit; + + /// Converts the platform's numeric value (in its native unit) into [unit]. + final double Function(double raw) toCategoryValue; + + /// Whether V1 imports this metric. Disabled ones are declared for visibility + /// and blocked on further groundwork (see [disabledReason]). + final bool enabled; + + /// Why a declared metric is not imported yet. + final String? disabledReason; +} + +/// Apple Health / Health Connect report body fat as a fraction on iOS (0.15) +/// but as a percentage on Health Connect (15). A real body fat percentage is +/// never below ~1.5, and a fraction is always below 1, so the magnitude tells +/// the two apart without branching on platform. +double _bodyFatToPercent(double raw) => raw <= 1.5 ? raw * 100 : raw; + +/// Height is reported in meters on both platforms (~1.75). Guard against a +/// value that already arrived in centimeters. +double _heightToCm(double raw) => raw < 3 ? raw * 100 : raw; + +double _identity(double raw) => raw; + +/// The V1 metric set (see `plan-measurements-health-v27.md`). +/// +/// Only [HealthMetric.enabled] entries are imported. The rest are declared so +/// the mapping is visible in one place; each is blocked on groundwork noted in +/// its [HealthMetric.disabledReason]. +const List healthMetrics = [ + HealthMetric( + metricType: 'body_fat', + dataType: HealthDataType.BODY_FAT_PERCENTAGE, + canonicalName: 'Body fat', + unit: '%', + toCategoryValue: _bodyFatToPercent, + enabled: true, + ), + HealthMetric( + metricType: 'height', + dataType: HealthDataType.HEIGHT, + canonicalName: 'Height', + unit: 'cm', + toCategoryValue: _heightToCm, + enabled: true, + ), + HealthMetric( + metricType: 'body_weight', + dataType: HealthDataType.WEIGHT, + canonicalName: 'Weight', + unit: 'kg', + toCategoryValue: _identity, + disabledReason: + 'Body weight lives in its own feature until the ' + 'weight/measurements merge (metric_type=body_weight).', + ), + HealthMetric( + metricType: 'blood_pressure', + dataType: HealthDataType.BLOOD_PRESSURE_SYSTOLIC, + canonicalName: 'Blood pressure', + unit: 'mmHg', + toCategoryValue: _identity, + disabledReason: 'Needs measurement grouping to pair systolic/diastolic.', + ), + HealthMetric( + metricType: 'heart_rate', + dataType: HealthDataType.HEART_RATE, + canonicalName: 'Heart rate', + unit: 'bpm', + toCategoryValue: _identity, + disabledReason: 'High-frequency; needs a per-day aggregation strategy.', + ), + HealthMetric( + metricType: 'steps', + dataType: HealthDataType.STEPS, + canonicalName: 'Steps', + unit: 'count', + toCategoryValue: _identity, + disabledReason: 'High-volume cumulative type, parked behind a load test.', + ), +]; + +/// The enabled subset that the importer actually pulls. +List get enabledHealthMetrics => healthMetrics.where((m) => m.enabled).toList(); diff --git a/lib/features/health/providers/health_sync.dart b/lib/features/health/providers/health_sync.dart new file mode 100644 index 000000000..e0e1a1971 --- /dev/null +++ b/lib/features/health/providers/health_sync.dart @@ -0,0 +1,250 @@ +/* + * 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 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:health/health.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync/powersync.dart' as ps; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wger/core/shared_preferences.dart'; +import 'package:wger/features/health/models/health_metric.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/measurement_entry.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; + +part 'health_sync.g.dart'; + +class HealthSyncState { + final bool isEnabled; + final bool isSyncing; + final int lastSyncCount; + + const HealthSyncState({ + this.isEnabled = false, + this.isSyncing = false, + this.lastSyncCount = 0, + }); + + HealthSyncState copyWith({bool? isEnabled, bool? isSyncing, int? lastSyncCount}) { + return HealthSyncState( + isEnabled: isEnabled ?? this.isEnabled, + isSyncing: isSyncing ?? this.isSyncing, + lastSyncCount: lastSyncCount ?? this.lastSyncCount, + ); + } +} + +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform, writes to the local Drift DB, and +/// lets PowerSync push the rows up. Re-imports are deduplicated via each +/// measurement's [MeasurementEntry.externalId] (the platform record UUID). +@Riverpod(keepAlive: true) +class HealthSyncNotifier extends _$HealthSyncNotifier { + final _logger = Logger('HealthSyncNotifier'); + late final Health _health; + late final MeasurementRepository _repo; + + List get _types => enabledHealthMetrics.map((m) => m.dataType).toList(); + + List get _readAccess => List.filled(_types.length, HealthDataAccess.READ); + + String get _sourceName => Platform.isIOS ? 'apple_health' : 'health_connect'; + + @override + HealthSyncState build() { + _health = Health(); + _repo = ref.read(measurementRepositoryProvider); + _loadPersistedState(); + return const HealthSyncState(); + } + + Future _loadPersistedState() async { + if (await PreferenceHelper.instance.getHealthSyncEnabled()) { + state = state.copyWith(isEnabled: true); + } + } + + /// Whether a health platform is available on this device. + Future isAvailable() async { + if (Platform.isAndroid) { + await _health.configure(); + final status = await _health.getHealthConnectSdkStatus(); + return status == HealthConnectSdkStatus.sdkAvailable; + } + return Platform.isIOS; + } + + /// Requests permissions, persists the preference, and runs an initial import. + Future enableSync() async { + _logger.info('Enabling health sync'); + await _health.configure(); + + final authorized = await _health.requestAuthorization(_types, permissions: _readAccess); + if (!authorized) { + _logger.warning('Health permissions not granted'); + return 0; + } + + if (Platform.isAndroid) { + await _health.requestHealthDataHistoryAuthorization(); + } + + await PreferenceHelper.instance.setHealthSyncEnabled(true); + state = state.copyWith(isEnabled: true); + + return syncOnAppOpen(); + } + + /// Clears the preference and disables importing. + Future disableSync() async { + _logger.info('Disabling health sync'); + await PreferenceHelper.instance.clearHealthSyncPreferences(); + state = const HealthSyncState(); + } + + /// Reads the enabled metrics from the health platform and writes any new + /// readings to the matching measurement categories. Returns the number of + /// imported entries. A no-op unless the user enabled sync. + Future syncOnAppOpen() async { + final prefs = PreferenceHelper.instance; + if (!await prefs.getHealthSyncEnabled()) { + return 0; + } + if (state.isSyncing) { + return 0; + } + state = state.copyWith(isEnabled: true, isSyncing: true); + + try { + await _health.configure(); + + final hasPerms = await _health.hasPermissions(_types, permissions: _readAccess); + if (hasPerms != true) { + final authorized = await _health.requestAuthorization(_types, permissions: _readAccess); + if (!authorized) { + _logger.warning('Health permissions not granted during sync'); + state = state.copyWith(isSyncing: false); + return 0; + } + } + + final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); + final startTime = lastSyncStr != null ? DateTime.parse(lastSyncStr) : DateTime(2000); + final endTime = DateTime.now(); + _logger.info('Syncing health data from $startTime to $endTime'); + + var points = await _health.getHealthDataFromTypes( + types: _types, + startTime: startTime, + endTime: endTime, + ); + points = _health.removeDuplicates(points); + if (points.isEmpty) { + _logger.info('No new health data'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + + final categories = await _repo.getAllOnce(); + var synced = 0; + DateTime? latest; + + for (final metric in enabledHealthMetrics) { + final metricPoints = points.where((p) => p.type == metric.dataType); + if (metricPoints.isEmpty) { + continue; + } + + final category = await _findOrCreateCategory(metric, categories); + final seen = { + for (final e in category.entries) + if (e.externalId != null) e.externalId!, + }; + + for (final point in metricPoints) { + final value = point.value; + if (value is! NumericHealthValue) { + continue; + } + final uuid = point.uuid; + if (uuid.isNotEmpty && seen.contains(uuid)) { + continue; + } + + await _repo.addLocalDrift( + MeasurementEntry( + categoryId: category.id!, + date: point.dateFrom, + value: metric.toCategoryValue(value.numericValue.toDouble()), + notes: '', + source: _sourceName, + externalId: uuid.isEmpty ? null : uuid, + ), + ); + + if (uuid.isNotEmpty) { + seen.add(uuid); + } + synced++; + if (latest == null || point.dateFrom.isAfter(latest)) { + latest = point.dateFrom; + } + } + } + + if (latest != null) { + await prefs.setLastHealthSyncTimestamp(latest.toIso8601String()); + } + _logger.info('Imported $synced health measurements'); + state = state.copyWith(isSyncing: false, lastSyncCount: synced); + return synced; + } catch (e) { + _logger.warning('Health sync failed: $e'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + } + + /// Finds the category for [metric] (by `metric_type`, falling back to the + /// canonical name once the server has nulled `metric_type` on a round-trip) + /// or creates it. The created category is appended to [categories] so a later + /// metric in the same run reuses it. + Future _findOrCreateCategory( + HealthMetric metric, + List categories, + ) async { + final existing = categories.firstWhereOrNull( + (c) => c.metricType == metric.metricType || c.name == metric.canonicalName, + ); + if (existing != null) { + return existing; + } + + final category = MeasurementCategory( + id: ps.uuid.v7(), + name: metric.canonicalName, + unit: metric.unit, + metricType: metric.metricType, + ); + await _repo.addLocalDriftCategory(category); + categories.add(category); + return category; + } +} diff --git a/lib/features/weight/providers/health_sync.g.dart b/lib/features/health/providers/health_sync.g.dart similarity index 56% rename from lib/features/weight/providers/health_sync.g.dart rename to lib/features/health/providers/health_sync.g.dart index 5c161334d..768adca84 100644 --- a/lib/features/weight/providers/health_sync.g.dart +++ b/lib/features/health/providers/health_sync.g.dart @@ -8,12 +8,24 @@ part of 'health_sync.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform, writes to the local Drift DB, and +/// lets PowerSync push the rows up. Re-imports are deduplicated via each +/// measurement's [MeasurementEntry.externalId] (the platform record UUID). @ProviderFor(HealthSyncNotifier) final healthSyncProvider = HealthSyncNotifierProvider._(); +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform, writes to the local Drift DB, and +/// lets PowerSync push the rows up. Re-imports are deduplicated via each +/// measurement's [MeasurementEntry.externalId] (the platform record UUID). final class HealthSyncNotifierProvider extends $NotifierProvider { + /// Imports body metrics from Apple Health / Health Connect into measurement + /// categories. Read-only: reads the platform, writes to the local Drift DB, and + /// lets PowerSync push the rows up. Re-imports are deduplicated via each + /// measurement's [MeasurementEntry.externalId] (the platform record UUID). HealthSyncNotifierProvider._() : super( from: null, @@ -41,7 +53,12 @@ final class HealthSyncNotifierProvider } } -String _$healthSyncNotifierHash() => r'451a05716a24477f7626a266aaac6a29c1707ab2'; +String _$healthSyncNotifierHash() => r'9efca0080b2dd104a3e409f1c81a9976ec0e635c'; + +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform, writes to the local Drift DB, and +/// lets PowerSync push the rows up. Re-imports are deduplicated via each +/// measurement's [MeasurementEntry.externalId] (the platform record UUID). abstract class _$HealthSyncNotifier extends $Notifier { HealthSyncState build(); diff --git a/lib/features/measurements/models/measurement_category.dart b/lib/features/measurements/models/measurement_category.dart index 3dd72751e..45683827a 100644 --- a/lib/features/measurements/models/measurement_category.dart +++ b/lib/features/measurements/models/measurement_category.dart @@ -45,11 +45,17 @@ class MeasurementCategory with _$MeasurementCategory { @override final List entries; + /// Drives the health-platform mapping (and, later, default unit/aggregation/ + /// chart). `null` for plain user-created ("custom") categories. + @override + final String? metricType; + MeasurementCategory({ this.id, this.name = '', this.unit = '', this.entries = const [], + this.metricType, }); MeasurementEntry findEntryById(String id) { @@ -65,6 +71,7 @@ class MeasurementCategory with _$MeasurementCategory { id: id != null ? Value(id!) : const Value.absent(), name: Value(name), unit: Value(unit), + metricType: Value(metricType), ); } } diff --git a/lib/features/measurements/models/measurement_category.freezed.dart b/lib/features/measurements/models/measurement_category.freezed.dart index 4d6754060..252d17ce7 100644 --- a/lib/features/measurements/models/measurement_category.freezed.dart +++ b/lib/features/measurements/models/measurement_category.freezed.dart @@ -15,7 +15,9 @@ T _$identity(T value) => value; mixin _$MeasurementCategory { /// Client-generated UUID, is `null` only before the first persist - String? get id; String get name; String get unit; List get entries; + String? get id; String get name; String get unit; List get entries;/// Drives the health-platform mapping (and, later, default unit/aggregation/ +/// chart). `null` for plain user-created ("custom") categories. + String? get metricType; /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +28,16 @@ $MeasurementCategoryCopyWith get copyWith => _$MeasurementC @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)&&(identical(other.metricType, metricType) || other.metricType == metricType)); } @override -int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries)); +int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries),metricType); @override String toString() { - return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries)'; + return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries, metricType: $metricType)'; } @@ -46,7 +48,7 @@ abstract mixin class $MeasurementCategoryCopyWith<$Res> { factory $MeasurementCategoryCopyWith(MeasurementCategory value, $Res Function(MeasurementCategory) _then) = _$MeasurementCategoryCopyWithImpl; @useResult $Res call({ - String? id, String name, String unit, List entries + String? id, String name, String unit, List entries, String? metricType }); @@ -63,13 +65,14 @@ class _$MeasurementCategoryCopyWithImpl<$Res> /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,Object? metricType = freezed,}) { return _then(MeasurementCategory( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable as String,entries: null == entries ? _self.entries : entries // ignore: cast_nullable_to_non_nullable -as List, +as List,metricType: freezed == metricType ? _self.metricType : metricType // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/features/measurements/models/measurement_entry.dart b/lib/features/measurements/models/measurement_entry.dart index 2d5e8f636..717408547 100644 --- a/lib/features/measurements/models/measurement_entry.dart +++ b/lib/features/measurements/models/measurement_entry.dart @@ -43,12 +43,24 @@ class MeasurementEntry with _$MeasurementEntry { @override final String notes; + /// Where the reading came from: `manual` or a health platform + /// (`apple_health`, `health_connect`). + @override + final String source; + + /// Platform record UUID, used to deduplicate re-imports. `null` for manual + /// entries. + @override + final String? externalId; + MeasurementEntry({ this.id, required this.categoryId, required this.date, required this.value, required this.notes, + this.source = 'manual', + this.externalId, }); // Boilerplate @@ -59,6 +71,8 @@ class MeasurementEntry with _$MeasurementEntry { date: Value(date), value: Value(value.toDouble()), notes: Value(notes), + source: Value(source), + externalId: Value(externalId), ); } } diff --git a/lib/features/measurements/models/measurement_entry.freezed.dart b/lib/features/measurements/models/measurement_entry.freezed.dart index e4423aa6a..4f0b855f7 100644 --- a/lib/features/measurements/models/measurement_entry.freezed.dart +++ b/lib/features/measurements/models/measurement_entry.freezed.dart @@ -15,7 +15,11 @@ T _$identity(T value) => value; mixin _$MeasurementEntry { /// Client-generated UUID, is `null` only before the first persist - String? get id; String get categoryId; DateTime get date; num get value; String get notes; + String? get id; String get categoryId; DateTime get date; num get value; String get notes;/// Where the reading came from: `manual` or a health platform +/// (`apple_health`, `health_connect`). + String get source;/// Platform record UUID, used to deduplicate re-imports. `null` for manual +/// entries. + String? get externalId; /// Create a copy of MeasurementEntry /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +30,16 @@ $MeasurementEntryCopyWith get copyWith => _$MeasurementEntryCo @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.categoryId, categoryId) || other.categoryId == categoryId)&&(identical(other.date, date) || other.date == date)&&(identical(other.value, value) || other.value == value)&&(identical(other.notes, notes) || other.notes == notes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.categoryId, categoryId) || other.categoryId == categoryId)&&(identical(other.date, date) || other.date == date)&&(identical(other.value, value) || other.value == value)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.source, source) || other.source == source)&&(identical(other.externalId, externalId) || other.externalId == externalId)); } @override -int get hashCode => Object.hash(runtimeType,id,categoryId,date,value,notes); +int get hashCode => Object.hash(runtimeType,id,categoryId,date,value,notes,source,externalId); @override String toString() { - return 'MeasurementEntry(id: $id, categoryId: $categoryId, date: $date, value: $value, notes: $notes)'; + return 'MeasurementEntry(id: $id, categoryId: $categoryId, date: $date, value: $value, notes: $notes, source: $source, externalId: $externalId)'; } @@ -46,7 +50,7 @@ abstract mixin class $MeasurementEntryCopyWith<$Res> { factory $MeasurementEntryCopyWith(MeasurementEntry value, $Res Function(MeasurementEntry) _then) = _$MeasurementEntryCopyWithImpl; @useResult $Res call({ - String? id, String categoryId, DateTime date, num value, String notes + String? id, String categoryId, DateTime date, num value, String notes, String source, String? externalId }); @@ -63,14 +67,16 @@ class _$MeasurementEntryCopyWithImpl<$Res> /// Create a copy of MeasurementEntry /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? categoryId = null,Object? date = null,Object? value = null,Object? notes = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? categoryId = null,Object? date = null,Object? value = null,Object? notes = null,Object? source = null,Object? externalId = freezed,}) { return _then(MeasurementEntry( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,categoryId: null == categoryId ? _self.categoryId : categoryId // ignore: cast_nullable_to_non_nullable as String,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable as DateTime,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable as num,notes: null == notes ? _self.notes : notes // ignore: cast_nullable_to_non_nullable -as String, +as String,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable +as String,externalId: freezed == externalId ? _self.externalId : externalId // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/features/measurements/providers/measurement_repository.dart b/lib/features/measurements/providers/measurement_repository.dart index 5763e6e03..06ce035fd 100644 --- a/lib/features/measurements/providers/measurement_repository.dart +++ b/lib/features/measurements/providers/measurement_repository.dart @@ -82,6 +82,9 @@ class MeasurementRepository { return watchAll().map((categories) => categories.firstWhereOrNull((c) => c.id == id)); } + /// One-shot snapshot of all categories with their entries. + Future> getAllOnce() => watchAll().first; + // Entries Future deleteLocalDrift(String id) async { _logger.finer('Deleting local measurement entry $id'); diff --git a/lib/features/weight/providers/health_sync.dart b/lib/features/weight/providers/health_sync.dart deleted file mode 100644 index 1604e1b0a..000000000 --- a/lib/features/weight/providers/health_sync.dart +++ /dev/null @@ -1,249 +0,0 @@ -/* - * 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 'dart:io'; - -import 'package:health/health.dart'; -import 'package:logging/logging.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:wger/core/shared_preferences.dart'; -import 'package:wger/features/weight/models/weight_entry.dart'; -import 'package:wger/features/weight/providers/body_weight_repository.dart'; - -part 'health_sync.g.dart'; - -class HealthSyncState { - final bool isEnabled; - final bool isSyncing; - final int lastSyncCount; - - const HealthSyncState({ - this.isEnabled = false, - this.isSyncing = false, - this.lastSyncCount = 0, - }); - - HealthSyncState copyWith({ - bool? isEnabled, - bool? isSyncing, - int? lastSyncCount, - }) { - return HealthSyncState( - isEnabled: isEnabled ?? this.isEnabled, - isSyncing: isSyncing ?? this.isSyncing, - lastSyncCount: lastSyncCount ?? this.lastSyncCount, - ); - } -} - -const double kgToLb = 2.20462; - -@Riverpod(keepAlive: true) -class HealthSyncNotifier extends _$HealthSyncNotifier { - final _logger = Logger('HealthSyncNotifier'); - late final Health _health; - late final BodyWeightRepository _repo; - - @override - HealthSyncState build() { - _health = Health(); - _repo = ref.read(bodyWeightRepositoryProvider); - - // Load persisted sync preference on startup - _loadPersistedState(); - - return const HealthSyncState(); - } - - Future _loadPersistedState() async { - final enabled = await PreferenceHelper.instance.getHealthSyncEnabled(); - if (enabled) { - state = state.copyWith(isEnabled: true); - } - } - - /// Check if the health platform is available on this device - Future isAvailable() async { - if (Platform.isAndroid) { - await _health.configure(); - final status = await _health.getHealthConnectSdkStatus(); - return status == HealthConnectSdkStatus.sdkAvailable; - } - // iOS always has HealthKit available - return Platform.isIOS; - } - - /// Enable health sync: request permissions, save preference, trigger initial sync. - /// If [isMetric] is false, converts kg values from the health platform to lb before POSTing. - Future enableSync({bool isMetric = true}) async { - _logger.info('Enabling health sync'); - - await _health.configure(); - - final authorized = await _health.requestAuthorization( - [HealthDataType.WEIGHT], - permissions: [HealthDataAccess.READ], - ); - - if (!authorized) { - _logger.warning('Health permissions not granted'); - return 0; - } - - // Request access to historical data (older than 30 days) on Android - if (Platform.isAndroid) { - await _health.requestHealthDataHistoryAuthorization(); - } - - await PreferenceHelper.instance.setHealthSyncEnabled(true); - state = state.copyWith(isEnabled: true); - - return syncOnAppOpen(isMetric: isMetric); - } - - /// Disable health sync: clear preferences - Future disableSync() async { - _logger.info('Disabling health sync'); - await PreferenceHelper.instance.clearHealthSyncPreferences(); - state = const HealthSyncState(); - } - - /// Main sync method: read weight data from health platform, post new entries to backend. - /// If [isMetric] is false, converts kg values from the health platform to lb before POSTing. - Future syncOnAppOpen({List? existingEntries, bool isMetric = true}) async { - final prefs = PreferenceHelper.instance; - final enabled = await prefs.getHealthSyncEnabled(); - if (!enabled) { - return 0; - } - - if (state.isSyncing) { - return 0; - } - state = state.copyWith(isEnabled: true, isSyncing: true); - - try { - await _health.configure(); - - // Ensure we have permission to read weight data - final hasPerms = await _health.hasPermissions( - [HealthDataType.WEIGHT], - permissions: [HealthDataAccess.READ], - ); - if (hasPerms != true) { - final authorized = await _health.requestAuthorization( - [HealthDataType.WEIGHT], - permissions: [HealthDataAccess.READ], - ); - if (!authorized) { - _logger.warning('Health permissions not granted during sync'); - state = state.copyWith(isSyncing: false); - return 0; - } - } - - // Determine the start time for the query - final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); - final DateTime startTime; - if (lastSyncStr != null) { - startTime = DateTime.parse(lastSyncStr); - } else { - // Pull all available history on first sync - startTime = DateTime(2000); - } - final endTime = DateTime.now(); - - _logger.info('Syncing weight data from $startTime to $endTime'); - - // Read weight data from health platform - List dataPoints = await _health.getHealthDataFromTypes( - types: [HealthDataType.WEIGHT], - startTime: startTime, - endTime: endTime, - ); - dataPoints = _health.removeDuplicates(dataPoints); - - if (dataPoints.isEmpty) { - _logger.info('No new weight data from health platform'); - state = state.copyWith(isSyncing: false, lastSyncCount: 0); - return 0; - } - - _logger.info('Found ${dataPoints.length} weight data points'); - - // Build a Set of existing timestamps for O(1) dedup lookups - final existingTimestamps = existingEntries != null - ? { - for (final e in existingEntries) - DateTime(e.date.year, e.date.month, e.date.day, e.date.hour, e.date.minute), - } - : {}; - - int syncedCount = 0; - DateTime? latestSynced; - - for (final point in dataPoints) { - try { - final value = (point.value as NumericHealthValue).numericValue; - final weightKg = value.toDouble(); - final timestamp = point.dateFrom; - - final weight = isMetric ? weightKg : weightKg * kgToLb; - final weightRounded = (weight * 100).roundToDouble() / 100; - - // Skip if an entry with the same timestamp already exists locally - final normalizedTimestamp = DateTime( - timestamp.year, - timestamp.month, - timestamp.day, - timestamp.hour, - timestamp.minute, - ); - if (existingTimestamps.contains(normalizedTimestamp)) { - _logger.fine('Skipping duplicate entry for $timestamp'); - continue; - } - - final entry = WeightEntry(weight: weightRounded, date: timestamp); - await _repo.addLocalDrift(entry); - - syncedCount++; - if (latestSynced == null || timestamp.isAfter(latestSynced)) { - latestSynced = timestamp; - } - } catch (e) { - _logger.warning('Failed to sync weight entry: $e'); - // Best-effort: continue with next entry - } - } - - // Update last sync timestamp to the latest successfully synced reading - if (latestSynced != null) { - await prefs.setLastHealthSyncTimestamp(latestSynced.toIso8601String()); - } - - _logger.info('Synced $syncedCount weight entries'); - state = state.copyWith(isSyncing: false, lastSyncCount: syncedCount); - return syncedCount; - } catch (e) { - _logger.warning('Health sync failed: $e'); - state = state.copyWith(isSyncing: false, lastSyncCount: 0); - return 0; - } - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0ec1bab34..3cc494838 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1439,11 +1439,11 @@ "@healthSync": { "description": "Title for the health platform sync setting" }, - "healthSyncDescription": "Import weight from Apple Health or Health Connect", + "healthSyncDescription": "Import body measurements from Apple Health or Health Connect", "@healthSyncDescription": { "description": "Subtitle for the health platform sync setting" }, - "healthSyncSuccess": "Synced {count} weight entries from Health", + "healthSyncSuccess": "Imported {count} measurements from Health", "@healthSyncSuccess": { "description": "Snackbar message after successful health sync", "placeholders": { diff --git a/lib/powersync/connector.dart b/lib/powersync/connector.dart index 532c15b74..17424003c 100644 --- a/lib/powersync/connector.dart +++ b/lib/powersync/connector.dart @@ -157,7 +157,10 @@ class DjangoConnector extends PowerSyncBackendConnector { if (k == 'id') { return; } - final key = k.endsWith('_id') ? k.substring(0, k.length - 3) : k; + // Trailing `_id` marks a foreign key, which Django exposes without the + // suffix (`category_id` -> `category`). `external_id` is a plain value + // column, not an FK, so it keeps its name. + final key = (k.endsWith('_id') && k != 'external_id') ? k.substring(0, k.length - 3) : k; out[key] = (dateFields.contains(key) && v is String && v.length >= 10) ? v.substring(0, 10) : v; diff --git a/test/features/account/widgets/settings_test.dart b/test/features/account/widgets/settings_test.dart index debe599e6..d6bce6230 100644 --- a/test/features/account/widgets/settings_test.dart +++ b/test/features/account/widgets/settings_test.dart @@ -25,7 +25,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wger/core/app_settings_notifier.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/features/account/widgets/settings.dart'; -import 'package:wger/features/weight/providers/health_sync.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'settings_test.mocks.dart'; diff --git a/test/features/health/models/health_metric_test.dart b/test/features/health/models/health_metric_test.dart new file mode 100644 index 000000000..2557e11ba --- /dev/null +++ b/test/features/health/models/health_metric_test.dart @@ -0,0 +1,79 @@ +/* + * 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:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/features/health/models/health_metric.dart'; + +HealthMetric _metric(String type) => healthMetrics.firstWhere((m) => m.metricType == type); + +void main() { + group('Enabled metric set', () { + test('only body fat and height are imported in V1', () { + expect( + enabledHealthMetrics.map((m) => m.metricType), + containsAll(['body_fat', 'height']), + ); + expect(enabledHealthMetrics.length, 2); + }); + + test('every disabled metric explains why', () { + final disabled = healthMetrics.where((m) => !m.enabled); + expect(disabled, isNotEmpty); + expect(disabled.every((m) => m.disabledReason != null), isTrue); + }); + + test('metric types are unique', () { + final types = healthMetrics.map((m) => m.metricType).toList(); + expect(types.length, types.toSet().length); + }); + }); + + group('Body fat conversion', () { + final bodyFat = _metric('body_fat'); + + test('iOS fraction is scaled to a percentage', () { + expect(bodyFat.toCategoryValue(0.15), closeTo(15, 0.001)); + }); + + test('Health Connect percentage is kept as-is', () { + expect(bodyFat.toCategoryValue(15), closeTo(15, 0.001)); + }); + }); + + group('Height conversion', () { + final height = _metric('height'); + + test('meters are converted to centimeters', () { + expect(height.toCategoryValue(1.75), closeTo(175, 0.001)); + }); + + test('a value already in centimeters is kept as-is', () { + expect(height.toCategoryValue(180), closeTo(180, 0.001)); + }); + }); + + test('enabledHealthMetrics matches the enabled flag', () { + expect( + enabledHealthMetrics, + healthMetrics.where((m) => m.enabled).toList(), + ); + // sanity: the disabled ones really are excluded + expect(enabledHealthMetrics.firstWhereOrNull((m) => m.metricType == 'steps'), isNull); + }); +} diff --git a/test/features/weight/providers/health_sync_test.dart b/test/features/health/providers/health_sync_test.dart similarity index 50% rename from test/features/weight/providers/health_sync_test.dart rename to test/features/health/providers/health_sync_test.dart index 407c6a316..6d16d92f3 100644 --- a/test/features/weight/providers/health_sync_test.dart +++ b/test/features/health/providers/health_sync_test.dart @@ -17,49 +17,9 @@ */ import 'package:flutter_test/flutter_test.dart'; -import 'package:wger/features/weight/providers/health_sync.dart'; - -/// Mirrors the conversion logic in HealthSyncNotifier.syncOnAppOpen -double _convertWeight(double weightKg, {required bool isMetric}) { - return isMetric ? weightKg : weightKg * kgToLb; -} +import 'package:wger/features/health/providers/health_sync.dart'; void main() { - group('Health sync constants', () { - test('kgToLb conversion factor is correct', () { - // 1 kg = 2.20462 lb - expect(kgToLb, closeTo(2.20462, 0.00001)); - }); - }); - - group('Weight unit conversion', () { - test('kg value is converted to lb correctly', () { - const weightKg = 85.0; - final weightLb = (weightKg * kgToLb * 100).roundToDouble() / 100; - expect(weightLb, closeTo(187.39, 0.01)); - }); - - test('kg value stays as-is when metric', () { - const weightKg = 85.0; - final weight = _convertWeight(weightKg, isMetric: true); - expect(weight, 85.0); - }); - - test('kg value is converted when imperial', () { - const weightKg = 85.0; - final weight = _convertWeight(weightKg, isMetric: false); - expect(weight, closeTo(187.39, 0.01)); - }); - - test('conversion rounds to 2 decimal places', () { - const weightKg = 85.12345; - const weight = weightKg * kgToLb; - final rounded = (weight * 100).roundToDouble() / 100; - // 85.12345 * 2.20462 = 187.66... - expect(rounded.toString().split('.').last.length, lessThanOrEqualTo(2)); - }); - }); - group('HealthSyncState', () { test('default state has sync disabled', () { const state = HealthSyncState(); diff --git a/test/features/measurements/providers/measurement_notifier_test.mocks.dart b/test/features/measurements/providers/measurement_notifier_test.mocks.dart index 4f7399345..5a502e6c9 100644 --- a/test/features/measurements/providers/measurement_notifier_test.mocks.dart +++ b/test/features/measurements/providers/measurement_notifier_test.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( diff --git a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart index 8b8ed109a..91c79d7fe 100644 --- a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( diff --git a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart index 8612dcbf4..15b6f34b1 100644 --- a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart @@ -60,6 +60,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( diff --git a/test/powersync/connector_test.dart b/test/powersync/connector_test.dart index abdcf059b..556bbf9b1 100644 --- a/test/powersync/connector_test.dart +++ b/test/powersync/connector_test.dart @@ -73,6 +73,16 @@ void main() { expect(out.containsKey('routine_id'), isFalse); }); + test('keeps `external_id`, which is a value column and not a foreign key', () { + final out = connector.genericTransform( + 'measurements_measurement', + {'external_id': 'abc-123', 'category_id': 5}, + '1', + ); + expect(out['external_id'], 'abc-123'); + expect(out['category'], 5); + }); + test('handles null opData (delete events)', () { final out = connector.genericTransform('manager_routine', null, '99'); expect(out, {'id': '99'}); diff --git a/test/screenshots/screenshots_01_dashboard.mocks.dart b/test/screenshots/screenshots_01_dashboard.mocks.dart index 82a19b2ff..f5c2b54c0 100644 --- a/test/screenshots/screenshots_01_dashboard.mocks.dart +++ b/test/screenshots/screenshots_01_dashboard.mocks.dart @@ -472,6 +472,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo ) as _i10.Stream<_i24.MeasurementCategory?>); + @override + _i10.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i10.Future>.value( + <_i24.MeasurementCategory>[], + ), + ) + as _i10.Future>); + @override _i10.Future deleteLocalDrift(String? id) => (super.noSuchMethod( diff --git a/test/screenshots/screenshots_04_measurements.mocks.dart b/test/screenshots/screenshots_04_measurements.mocks.dart index ba5ffa86f..686d60878 100644 --- a/test/screenshots/screenshots_04_measurements.mocks.dart +++ b/test/screenshots/screenshots_04_measurements.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( From 01c13b1ad65a897d1fd0408c80c45f648495c16f Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 2 Jul 2026 22:07:03 +0200 Subject: [PATCH 11/22] Add health repository This follows the convention in the rest of the application and allows us to easily mock it for tests. --- .../health/models/health_reading.dart | 59 ++++++ .../health/providers/health_repository.dart | 84 +++++++++ .../health/providers/health_sync.dart | 101 ++++------ .../health/providers/health_sync.g.dart | 30 +-- .../health/providers/health_sync_test.dart | 148 +++++++++++++++ .../providers/health_sync_test.mocks.dart | 177 ++++++++++++++++++ 6 files changed, 518 insertions(+), 81 deletions(-) create mode 100644 lib/features/health/models/health_reading.dart create mode 100644 lib/features/health/providers/health_repository.dart create mode 100644 test/features/health/providers/health_sync_test.mocks.dart diff --git a/lib/features/health/models/health_reading.dart b/lib/features/health/models/health_reading.dart new file mode 100644 index 000000000..1a6e52200 --- /dev/null +++ b/lib/features/health/models/health_reading.dart @@ -0,0 +1,59 @@ +/* + * 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:health/health.dart'; + +/// A single reading pulled from a health platform, reduced to the fields the +/// importer needs. Keeps the `health` package's [HealthDataPoint] out of the +/// notifier so the sync logic can be tested with plain values. +class HealthReading { + const HealthReading({ + required this.type, + required this.value, + required this.date, + this.externalId, + }); + + /// The platform type this reading belongs to (matched against + /// `HealthMetric.dataType`). + final HealthDataType type; + + /// Numeric value in the platform's native unit. + final double value; + + /// Start of the reading. + final DateTime date; + + /// Platform record UUID for deduplication; `null` when the platform gives none. + final String? externalId; + + /// Builds a reading from a platform data point, or `null` for a non-numeric + /// point (which the importer cannot store). + static HealthReading? fromDataPoint(HealthDataPoint point) { + final value = point.value; + if (value is! NumericHealthValue) { + return null; + } + return HealthReading( + type: point.type, + value: value.numericValue.toDouble(), + date: point.dateFrom, + externalId: point.uuid.isEmpty ? null : point.uuid, + ); + } +} diff --git a/lib/features/health/providers/health_repository.dart b/lib/features/health/providers/health_repository.dart new file mode 100644 index 000000000..942d68d07 --- /dev/null +++ b/lib/features/health/providers/health_repository.dart @@ -0,0 +1,84 @@ +/* + * 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 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:health/health.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/features/health/models/health_reading.dart'; + +final healthRepositoryProvider = Provider((ref) { + return HealthRepository(); +}); + +/// Wraps the `health` plugin so the rest of the app talks to a small, mockable +/// surface instead of Apple Health / Health Connect directly. +class HealthRepository { + HealthRepository([Health? health]) : _health = health ?? Health(); + + final Health _health; + final _logger = Logger('HealthRepository'); + + /// Identifies which platform a reading came from. + String get sourceName => Platform.isIOS ? 'apple_health' : 'health_connect'; + + /// Whether a health platform is usable on this device. + Future isAvailable() async { + if (Platform.isAndroid) { + await _health.configure(); + final status = await _health.getHealthConnectSdkStatus(); + return status == HealthConnectSdkStatus.sdkAvailable; + } + return Platform.isIOS; + } + + /// Ensures READ access to [types] (requesting it if needed) and, on Android, + /// access to historical data. Returns whether access is granted. + Future ensureAuthorized(List types) async { + await _health.configure(); + final access = List.filled(types.length, HealthDataAccess.READ); + + final hasPerms = await _health.hasPermissions(types, permissions: access); + if (hasPerms != true) { + final granted = await _health.requestAuthorization(types, permissions: access); + if (!granted) { + _logger.warning('Health permissions not granted'); + return false; + } + } + + if (Platform.isAndroid) { + await _health.requestHealthDataHistoryAuthorization(); + } + return true; + } + + /// Reads all [types] between [start] and [end], platform-deduplicated and + /// reduced to numeric [HealthReading]s. + Future> read({ + required List types, + required DateTime start, + required DateTime end, + }) async { + final points = _health.removeDuplicates( + await _health.getHealthDataFromTypes(types: types, startTime: start, endTime: end), + ); + return points.map(HealthReading.fromDataPoint).whereType().toList(); + } +} diff --git a/lib/features/health/providers/health_sync.dart b/lib/features/health/providers/health_sync.dart index e0e1a1971..6cf3c74a6 100644 --- a/lib/features/health/providers/health_sync.dart +++ b/lib/features/health/providers/health_sync.dart @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import 'dart:io'; - import 'package:collection/collection.dart'; import 'package:health/health.dart'; import 'package:logging/logging.dart'; @@ -25,6 +23,7 @@ import 'package:powersync/powersync.dart' as ps; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wger/core/shared_preferences.dart'; import 'package:wger/features/health/models/health_metric.dart'; +import 'package:wger/features/health/providers/health_repository.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/models/measurement_entry.dart'; import 'package:wger/features/measurements/providers/measurement_repository.dart'; @@ -52,25 +51,22 @@ class HealthSyncState { } /// Imports body metrics from Apple Health / Health Connect into measurement -/// categories. Read-only: reads the platform, writes to the local Drift DB, and -/// lets PowerSync push the rows up. Re-imports are deduplicated via each -/// measurement's [MeasurementEntry.externalId] (the platform record UUID). +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). @Riverpod(keepAlive: true) class HealthSyncNotifier extends _$HealthSyncNotifier { final _logger = Logger('HealthSyncNotifier'); - late final Health _health; - late final MeasurementRepository _repo; + late final HealthRepository _health; + late final MeasurementRepository _measurements; List get _types => enabledHealthMetrics.map((m) => m.dataType).toList(); - List get _readAccess => List.filled(_types.length, HealthDataAccess.READ); - - String get _sourceName => Platform.isIOS ? 'apple_health' : 'health_connect'; - @override HealthSyncState build() { - _health = Health(); - _repo = ref.read(measurementRepositoryProvider); + _health = ref.read(healthRepositoryProvider); + _measurements = ref.read(measurementRepositoryProvider); _loadPersistedState(); return const HealthSyncState(); } @@ -82,33 +78,16 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { } /// Whether a health platform is available on this device. - Future isAvailable() async { - if (Platform.isAndroid) { - await _health.configure(); - final status = await _health.getHealthConnectSdkStatus(); - return status == HealthConnectSdkStatus.sdkAvailable; - } - return Platform.isIOS; - } + Future isAvailable() => _health.isAvailable(); /// Requests permissions, persists the preference, and runs an initial import. Future enableSync() async { _logger.info('Enabling health sync'); - await _health.configure(); - - final authorized = await _health.requestAuthorization(_types, permissions: _readAccess); - if (!authorized) { - _logger.warning('Health permissions not granted'); + if (!await _health.ensureAuthorized(_types)) { return 0; } - - if (Platform.isAndroid) { - await _health.requestHealthDataHistoryAuthorization(); - } - await PreferenceHelper.instance.setHealthSyncEnabled(true); state = state.copyWith(isEnabled: true); - return syncOnAppOpen(); } @@ -133,16 +112,10 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { state = state.copyWith(isEnabled: true, isSyncing: true); try { - await _health.configure(); - - final hasPerms = await _health.hasPermissions(_types, permissions: _readAccess); - if (hasPerms != true) { - final authorized = await _health.requestAuthorization(_types, permissions: _readAccess); - if (!authorized) { - _logger.warning('Health permissions not granted during sync'); - state = state.copyWith(isSyncing: false); - return 0; - } + if (!await _health.ensureAuthorized(_types)) { + _logger.warning('Health permissions not granted during sync'); + state = state.copyWith(isSyncing: false); + return 0; } final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); @@ -150,25 +123,21 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { final endTime = DateTime.now(); _logger.info('Syncing health data from $startTime to $endTime'); - var points = await _health.getHealthDataFromTypes( - types: _types, - startTime: startTime, - endTime: endTime, - ); - points = _health.removeDuplicates(points); - if (points.isEmpty) { + final readings = await _health.read(types: _types, start: startTime, end: endTime); + if (readings.isEmpty) { _logger.info('No new health data'); state = state.copyWith(isSyncing: false, lastSyncCount: 0); return 0; } - final categories = await _repo.getAllOnce(); + final categories = await _measurements.getAllOnce(); + final source = _health.sourceName; var synced = 0; DateTime? latest; for (final metric in enabledHealthMetrics) { - final metricPoints = points.where((p) => p.type == metric.dataType); - if (metricPoints.isEmpty) { + final metricReadings = readings.where((r) => r.type == metric.dataType); + if (metricReadings.isEmpty) { continue; } @@ -178,33 +147,29 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { if (e.externalId != null) e.externalId!, }; - for (final point in metricPoints) { - final value = point.value; - if (value is! NumericHealthValue) { - continue; - } - final uuid = point.uuid; - if (uuid.isNotEmpty && seen.contains(uuid)) { + for (final reading in metricReadings) { + final uuid = reading.externalId; + if (uuid != null && seen.contains(uuid)) { continue; } - await _repo.addLocalDrift( + await _measurements.addLocalDrift( MeasurementEntry( categoryId: category.id!, - date: point.dateFrom, - value: metric.toCategoryValue(value.numericValue.toDouble()), + date: reading.date, + value: metric.toCategoryValue(reading.value), notes: '', - source: _sourceName, - externalId: uuid.isEmpty ? null : uuid, + source: source, + externalId: uuid, ), ); - if (uuid.isNotEmpty) { + if (uuid != null) { seen.add(uuid); } synced++; - if (latest == null || point.dateFrom.isAfter(latest)) { - latest = point.dateFrom; + if (latest == null || reading.date.isAfter(latest)) { + latest = reading.date; } } } @@ -243,7 +208,7 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { unit: metric.unit, metricType: metric.metricType, ); - await _repo.addLocalDriftCategory(category); + await _measurements.addLocalDriftCategory(category); categories.add(category); return category; } diff --git a/lib/features/health/providers/health_sync.g.dart b/lib/features/health/providers/health_sync.g.dart index 768adca84..686cde9b1 100644 --- a/lib/features/health/providers/health_sync.g.dart +++ b/lib/features/health/providers/health_sync.g.dart @@ -9,23 +9,26 @@ part of 'health_sync.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning /// Imports body metrics from Apple Health / Health Connect into measurement -/// categories. Read-only: reads the platform, writes to the local Drift DB, and -/// lets PowerSync push the rows up. Re-imports are deduplicated via each -/// measurement's [MeasurementEntry.externalId] (the platform record UUID). +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). @ProviderFor(HealthSyncNotifier) final healthSyncProvider = HealthSyncNotifierProvider._(); /// Imports body metrics from Apple Health / Health Connect into measurement -/// categories. Read-only: reads the platform, writes to the local Drift DB, and -/// lets PowerSync push the rows up. Re-imports are deduplicated via each -/// measurement's [MeasurementEntry.externalId] (the platform record UUID). +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). final class HealthSyncNotifierProvider extends $NotifierProvider { /// Imports body metrics from Apple Health / Health Connect into measurement - /// categories. Read-only: reads the platform, writes to the local Drift DB, and - /// lets PowerSync push the rows up. Re-imports are deduplicated via each - /// measurement's [MeasurementEntry.externalId] (the platform record UUID). + /// categories. Read-only: reads the platform (via [HealthRepository]), writes to + /// the local Drift DB, and lets PowerSync push the rows up. Re-imports are + /// deduplicated via each measurement's [MeasurementEntry.externalId] (the + /// platform record UUID). HealthSyncNotifierProvider._() : super( from: null, @@ -53,12 +56,13 @@ final class HealthSyncNotifierProvider } } -String _$healthSyncNotifierHash() => r'9efca0080b2dd104a3e409f1c81a9976ec0e635c'; +String _$healthSyncNotifierHash() => r'9c7a678e7740bfa32db7638bd227d64da3bfef09'; /// Imports body metrics from Apple Health / Health Connect into measurement -/// categories. Read-only: reads the platform, writes to the local Drift DB, and -/// lets PowerSync push the rows up. Re-imports are deduplicated via each -/// measurement's [MeasurementEntry.externalId] (the platform record UUID). +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). abstract class _$HealthSyncNotifier extends $Notifier { HealthSyncState build(); diff --git a/test/features/health/providers/health_sync_test.dart b/test/features/health/providers/health_sync_test.dart index 6d16d92f3..54ffdb88d 100644 --- a/test/features/health/providers/health_sync_test.dart +++ b/test/features/health/providers/health_sync_test.dart @@ -16,9 +16,24 @@ * along with this program. If not, see . */ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:health/health.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.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/health/models/health_reading.dart'; +import 'package:wger/features/health/providers/health_repository.dart'; import 'package:wger/features/health/providers/health_sync.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/measurement_entry.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; +import 'health_sync_test.mocks.dart'; + +@GenerateMocks([HealthRepository, MeasurementRepository]) void main() { group('HealthSyncState', () { test('default state has sync disabled', () { @@ -36,4 +51,137 @@ void main() { expect(updated.lastSyncCount, 5); }); }); + + group('syncOnAppOpen', () { + late MockHealthRepository health; + late MockMeasurementRepository measurements; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + await PreferenceHelper.instance.setHealthSyncEnabled(true); + + health = MockHealthRepository(); + measurements = MockMeasurementRepository(); + + when(health.ensureAuthorized(any)).thenAnswer((_) async => true); + when(health.sourceName).thenReturn('apple_health'); + when(measurements.addLocalDrift(any)).thenAnswer((_) async {}); + when(measurements.addLocalDriftCategory(any)).thenAnswer((_) async {}); + }); + + HealthSyncNotifier createNotifier() { + final container = ProviderContainer.test( + overrides: [ + healthRepositoryProvider.overrideWithValue(health), + measurementRepositoryProvider.overrideWithValue(measurements), + ], + ); + return container.read(healthSyncProvider.notifier); + } + + void stubReadings(List readings) { + when( + health.read( + types: anyNamed('types'), + start: anyNamed('start'), + end: anyNamed('end'), + ), + ).thenAnswer((_) async => readings); + } + + test('imports enabled metrics into new categories with converted values', () async { + when(measurements.getAllOnce()).thenAnswer((_) async => []); + stubReadings([ + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.2, + date: DateTime(2026, 1, 1), + externalId: 'bf-1', + ), + HealthReading( + type: HealthDataType.HEIGHT, + value: 1.8, + date: DateTime(2026, 1, 2), + externalId: 'h-1', + ), + ]); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 2); + + final createdCategories = verify( + measurements.addLocalDriftCategory(captureAny), + ).captured.cast(); + expect(createdCategories.map((c) => c.metricType), containsAll(['body_fat', 'height'])); + + final entries = verify( + measurements.addLocalDrift(captureAny), + ).captured.cast(); + + final bodyFat = entries.firstWhere((e) => e.externalId == 'bf-1'); + expect(bodyFat.value, closeTo(20, 0.001)); // fraction -> percent + expect(bodyFat.source, 'apple_health'); + + final height = entries.firstWhere((e) => e.externalId == 'h-1'); + expect(height.value, closeTo(180, 0.001)); // meters -> cm + }); + + test('skips readings already imported (dedup by externalId)', () async { + final existing = MeasurementCategory( + id: 'cat-bf', + name: 'Body fat', + unit: '%', + metricType: 'body_fat', + entries: [ + MeasurementEntry( + id: 'e1', + categoryId: 'cat-bf', + date: DateTime(2026, 1, 1), + value: 20, + notes: '', + source: 'apple_health', + externalId: 'bf-1', + ), + ], + ); + when(measurements.getAllOnce()).thenAnswer((_) async => [existing]); + stubReadings([ + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.2, + date: DateTime(2026, 1, 1), + externalId: 'bf-1', // already imported + ), + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.22, + date: DateTime(2026, 1, 3), + externalId: 'bf-2', // new + ), + ]); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 1); + verifyNever(measurements.addLocalDriftCategory(any)); + + final entries = verify( + measurements.addLocalDrift(captureAny), + ).captured.cast(); + expect(entries.single.externalId, 'bf-2'); + }); + + test('does nothing when sync is disabled', () async { + await PreferenceHelper.instance.setHealthSyncEnabled(false); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 0); + verifyNever( + health.read( + types: anyNamed('types'), + start: anyNamed('start'), + end: anyNamed('end'), + ), + ); + }); + }); } diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart new file mode 100644 index 000000000..938babcd9 --- /dev/null +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -0,0 +1,177 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/features/health/providers/health_sync_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:health/health.dart' as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i3; +import 'package:wger/features/health/models/health_reading.dart' as _i6; +import 'package:wger/features/health/providers/health_repository.dart' as _i2; +import 'package:wger/features/measurements/models/measurement_category.dart' as _i8; +import 'package:wger/features/measurements/models/measurement_entry.dart' as _i9; +import 'package:wger/features/measurements/providers/measurement_repository.dart' as _i7; + +// 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 [HealthRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository { + MockHealthRepository() { + _i1.throwOnMissingStub(this); + } + + @override + String get sourceName => + (super.noSuchMethod( + Invocation.getter(#sourceName), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#sourceName), + ), + ) + as String); + + @override + _i4.Future isAvailable() => + (super.noSuchMethod( + Invocation.method(#isAvailable, []), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future ensureAuthorized(List<_i5.HealthDataType>? types) => + (super.noSuchMethod( + Invocation.method(#ensureAuthorized, [types]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future> read({ + required List<_i5.HealthDataType>? types, + required DateTime? start, + required DateTime? end, + }) => + (super.noSuchMethod( + Invocation.method(#read, [], { + #types: types, + #start: start, + #end: end, + }), + returnValue: _i4.Future>.value( + <_i6.HealthReading>[], + ), + ) + as _i4.Future>); +} + +/// A class which mocks [MeasurementRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementRepository extends _i1.Mock implements _i7.MeasurementRepository { + MockMeasurementRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Stream> watchAll() => + (super.noSuchMethod( + Invocation.method(#watchAll, []), + returnValue: _i4.Stream>.empty(), + ) + as _i4.Stream>); + + @override + _i4.Stream<_i8.MeasurementCategory?> watchLocalDriftCategoryById( + String? id, + ) => + (super.noSuchMethod( + Invocation.method(#watchLocalDriftCategoryById, [id]), + returnValue: _i4.Stream<_i8.MeasurementCategory?>.empty(), + ) + as _i4.Stream<_i8.MeasurementCategory?>); + + @override + _i4.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i4.Future>.value( + <_i8.MeasurementCategory>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future deleteLocalDrift(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDrift, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future updateLocalDrift(_i9.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#updateLocalDrift, [entry]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future addLocalDrift(_i9.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addLocalDrift, [entry]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future deleteLocalDriftCategory(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDriftCategory, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future updateLocalDriftCategory( + _i8.MeasurementCategory? category, + ) => + (super.noSuchMethod( + Invocation.method(#updateLocalDriftCategory, [category]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future addLocalDriftCategory(_i8.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftCategory, [category]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +} From 84d0de78c61c825f4a4b8106f6ae5a22e8e8362d Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:06:36 +0545 Subject: [PATCH 12/22] feat(measurements): introduce MetricType enum and category properties - Add MetricType enum supporting Django API field mapping - Provide localized en string descriptors for health categories - implement daily aggregation logic and bar chart widget - Implement MeasurementBarChartWidgetFl using fl_chart - Update getOverviewWidgets signatures to dynamically handle MetricType - Add buildChartForMetricType conditional rendering interceptor - Bind step, distance, sleep, and energy categories to the bar chart pipeline --- lib/l10n/app_en.arb | 40 ++++++ .../measurements/measurement_category.dart | 64 +++++++++ lib/widgets/measurements/charts.dart | 131 ++++++++++++++++++ lib/widgets/measurements/helpers.dart | 32 ++++- 4 files changed, 262 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b185a7107..4aa2d492f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -542,6 +542,46 @@ }, "measurementCategoriesHelpText": "Measurement category, such as 'biceps' or 'body fat'", "@measurementCategoriesHelpText": {}, + "metricCustom": "Custom", + "@metricCustom": { + "description": "Label for custom metric type" + }, + "metricBodyWeight": "Body weight", + "@metricBodyWeight": { + "description": "Label for body weight measurement" + }, + "metricBodyFat": "Body fat", + "@metricBodyFat": { + "description": "Label for body fat percentage measurement" + }, + "metricHeight": "Height", + "@metricHeight": { + "description": "Label for height measurement" + }, + "metricBloodPressure": "Blood pressure", + "@metricBloodPressure": { + "description": "Label for blood pressure measurement" + }, + "metricHeartRate": "Heart rate", + "@metricHeartRate": { + "description": "Label for heart rate measurement" + }, + "metricSteps": "Steps", + "@metricSteps": { + "description": "Label for step count measurement" + }, + "metricDistance": "Distance", + "@metricDistance": { + "description": "Label for distance measurement" + }, + "metricEnergy": "Energy", + "@metricEnergy": { + "description": "Label for energy/calories burned measurement" + }, + "metricSleep": "Sleep", + "@metricSleep": { + "description": "Label for sleep duration measurement" + }, "measurementEntriesHelpText": "The unit used to measure the category such as 'cm' or '%'", "@measurementEntriesHelpText": {}, "date": "Date", diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index 272e3724c..fa9d9f74e 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -17,13 +17,73 @@ */ import 'package:drift/drift.dart' hide JsonKey; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/database/powersync/database.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; part 'measurement_category.freezed.dart'; +/// The wire values mirror the Django API field names (e.g., 'body_weight', 'heart_rate'), +enum MetricType { + custom('custom'), + bodyWeight('body_weight'), + bodyFat('body_fat'), + height('height'), + bloodPressure('blood_pressure'), + heartRate('heart_rate'), + steps('steps'), + distance('distance'), + energy('energy'), + sleep('sleep'); + + final String wireValue; + const MetricType(this.wireValue); + + /// Looks up an enum case by its Django wire value. + /// + /// Defaults to [MetricType.custom] if the value is not recognized. + static MetricType fromWire(String value) => MetricType.values.firstWhere( + (e) => e.wireValue == value, + orElse: () => MetricType.custom, + ); + + /// `true` for metric types that should be aggregated per-day and shown as + /// a bar/histogram instead of a raw-sample line chart. + bool get isSummedPerDay => switch (this) { + MetricType.steps || + MetricType.distance || + MetricType.energy || + MetricType.sleep => true, + _ => false, + }; +} + +extension MeasurementMetricTypeL10n on MetricType { + /// Localized human-readable label (e.g., "Body Weight", "Heart Rate"). + String localized(BuildContext context) { + final l10n = AppLocalizations.of(context); + return switch (this) { + MetricType.custom => l10n.metricCustom, + MetricType.bodyWeight => l10n.metricBodyWeight, + MetricType.bodyFat => l10n.metricBodyFat, + MetricType.height => l10n.metricHeight, + MetricType.bloodPressure => l10n.metricBloodPressure, + MetricType.heartRate => l10n.metricHeartRate, + MetricType.steps => l10n.metricSteps, + MetricType.distance => l10n.metricDistance, + MetricType.energy => l10n.metricEnergy, + MetricType.sleep => l10n.metricSleep, + }; + } +} + +// Missing concrete implementation of 'getter _$MeasurementCategory.metricType'. +// Try implementing the missing method, or make the class abstract.dartnon_abstract_class_inherits_abstract_member +// class MeasurementCategory with _$MeasurementCategory +// Declared in package:wger/models/measurements/measurement_category.dart. @freezed class MeasurementCategory with _$MeasurementCategory { /// Inclusive upper bound for [name] @@ -45,11 +105,15 @@ class MeasurementCategory with _$MeasurementCategory { @override final List entries; + @override + final MetricType metricType; + MeasurementCategory({ this.id, this.name = '', this.unit = '', this.entries = const [], + this.metricType = MetricType.custom, }); MeasurementEntry findEntryById(String id) { diff --git a/lib/widgets/measurements/charts.dart b/lib/widgets/measurements/charts.dart index fa3ddbc18..049b68e3e 100644 --- a/lib/widgets/measurements/charts.dart +++ b/lib/widgets/measurements/charts.dart @@ -22,6 +22,7 @@ import 'package:intl/intl.dart'; import 'package:wger/helpers/charts.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; class MeasurementOverallChangeWidget extends StatelessWidget { final MeasurementChartEntry _first; @@ -251,6 +252,136 @@ List moving7dAverage(List vals) { return out; } +/// Sums entries per calendar day. Used for metric types where individual +/// samples aren't meaningful on their own (steps, distance, energy, sleep) — +/// see MeasurementMetricType.isSummedPerDay. +List aggregatePerDay(List vals) { + if (vals.isEmpty) { + return []; + } + + // Bucket by the day component only (strip time-of-day). + final Map sums = {}; + for (final e in vals) { + final day = DateTime(e.date.year, e.date.month, e.date.day); + sums.update(day, (existing) => existing + e.value, ifAbsent: () => e.value); + } + + final out = sums.entries.map((e) => MeasurementChartEntry(e.value, e.key)).toList() + ..sort((a, b) => a.date.compareTo(b.date)); + return out; +} + +class MeasurementBarChartWidgetFl extends StatefulWidget { + final List _entries; + final String _unit; + + const MeasurementBarChartWidgetFl(this._entries, this._unit); + + @override + State createState() => _MeasurementBarChartWidgetFlState(); +} + +class _MeasurementBarChartWidgetFlState extends State { + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1.70, + child: Padding( + padding: const EdgeInsets.all(4), + child: BarChart(mainData()), + ), + ); + } + + BarTouchData tooltipData() { + return BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (group) => Theme.of(context).colorScheme.primaryContainer, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final DateTime date = DateTime.fromMillisecondsSinceEpoch(group.x); + final dateStr = DateFormat.Md(Localizations.localeOf(context).languageCode).format(date); + + return BarTooltipItem( + '$dateStr: ${numberFormat.format(rod.toY)} ${widget._unit}', + TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer), + ); + }, + ), + ); + } + + BarChartData mainData() { + final String locale = Localizations.localeOf(context).toString(); + final NumberFormat numberFormat = NumberFormat.decimalPattern(locale); + return BarChartData( + barTouchData: tooltipData(), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) { + return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1); + }, + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + // avoid label overlap with the axis edges + if (value == meta.min || value == meta.max) { + return const Text(''); + } + final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); + return Text(DateFormat.Md(Localizations.localeOf(context).languageCode).format(date)); + }, + interval: widget._entries.isNotEmpty + ? chartGetInterval(widget._entries.last.date, widget._entries.first.date) + : CHART_MILLISECOND_FACTOR, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 65, + getTitlesWidget: (value, meta) { + if (value == meta.min || value == meta.max) { + return const Text(''); + } + return Text('${numberFormat.format(value)} ${widget._unit}'); + }, + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border.all(color: Theme.of(context).colorScheme.primaryContainer), + ), + barGroups: widget._entries + .map( + (e) => BarChartGroupData( + x: e.date.millisecondsSinceEpoch, + barRods: [ + BarChartRodData( + toY: e.value.toDouble(), + color: Theme.of(context).colorScheme.primary, + width: 12, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + ), + ], + ), + ) + .toList(), + ); + } +} + class Indicator extends StatelessWidget { const Indicator({ super.key, diff --git a/lib/widgets/measurements/helpers.dart b/lib/widgets/measurements/helpers.dart index 00369b06d..9086f568d 100644 --- a/lib/widgets/measurements/helpers.dart +++ b/lib/widgets/measurements/helpers.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wger/helpers/measurements.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/widgets/measurements/charts.dart'; @@ -9,8 +10,9 @@ List getOverviewWidgets( List raw, List avg, String unit, - BuildContext context, -) { + BuildContext context, { + MetricType metricType = MetricType.custom, +}) { return [ Text( title, @@ -29,7 +31,7 @@ List getOverviewWidgets( ), ), ) - : MeasurementChartWidgetFl(raw, unit, avgs: avg), + : buildChartForMetricType(metricType, raw, avg, unit), ), if (avg.isNotEmpty) MeasurementOverallChangeWidget(avg.first, avg.last, unit), const SizedBox(height: 8), @@ -42,8 +44,9 @@ List getOverviewWidgetsSeries( List entries7dAvg, List plans, String unit, - BuildContext context, -) { + BuildContext context, { + MetricType metricType = MetricType.custom, +}) { final monthAgo = DateTime.now().subtract(const Duration(days: 30)); return [ ...getOverviewWidgets( @@ -52,6 +55,7 @@ List getOverviewWidgetsSeries( entries7dAvg, unit, context, + metricType: metricType, ), // Show overview widgets for each plan in plans for (final plan in plans) @@ -61,6 +65,7 @@ List getOverviewWidgetsSeries( entries7dAvg.whereDateWithInterpolation(plan.startDate, plan.endDate), unit, context, + metricType: metricType, ), // if all time is significantly longer than 30 days (let's say > 75 days) // then let's show a separate chart just focusing on the last 30 days, @@ -74,6 +79,7 @@ List getOverviewWidgetsSeries( entries7dAvg.whereDateWithInterpolation(monthAgo, null), unit, context, + metricType: metricType, ), // legend Row( @@ -120,3 +126,19 @@ List getOverviewWidgetsSeries( } return (entriesAll, entries7dAvg); } + +Widget buildChartForMetricType( + MetricType metricType, + List raw, + List avg, + String unit, +) { + if (metricType.isSummedPerDay) { + return MeasurementBarChartWidgetFl(aggregatePerDay(raw), unit); + } + return MeasurementChartWidgetFl( + raw, + unit, + avgs: avg, + ); +} From 5d1f0372195a23a225a87ad3d3c1fe54d96d9f0c Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:51:55 +0545 Subject: [PATCH 13/22] feat(measurements): add metric type selection and detail view support - Replace hardcoded line charts with buildChartForMetricType - add metric type selection and detail view support - Add DropdownButtonFormField to MeasurementCategory form for user assignment - Pass metricType to detail view rendering pipeline in EntriesList - Update form state management to handle manual metric type assignment --- lib/models/measurements/measurement_category.dart | 11 ++--------- lib/widgets/measurements/categories_card.dart | 8 +++++--- lib/widgets/measurements/entries.dart | 1 + lib/widgets/measurements/forms.dart | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index fa9d9f74e..01a4c1cc1 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -43,7 +43,7 @@ enum MetricType { const MetricType(this.wireValue); /// Looks up an enum case by its Django wire value. - /// + /// /// Defaults to [MetricType.custom] if the value is not recognized. static MetricType fromWire(String value) => MetricType.values.firstWhere( (e) => e.wireValue == value, @@ -53,10 +53,7 @@ enum MetricType { /// `true` for metric types that should be aggregated per-day and shown as /// a bar/histogram instead of a raw-sample line chart. bool get isSummedPerDay => switch (this) { - MetricType.steps || - MetricType.distance || - MetricType.energy || - MetricType.sleep => true, + MetricType.steps || MetricType.distance || MetricType.energy || MetricType.sleep => true, _ => false, }; } @@ -80,10 +77,6 @@ extension MeasurementMetricTypeL10n on MetricType { } } -// Missing concrete implementation of 'getter _$MeasurementCategory.metricType'. -// Try implementing the missing method, or make the class abstract.dartnon_abstract_class_inherits_abstract_member -// class MeasurementCategory with _$MeasurementCategory -// Declared in package:wger/models/measurements/measurement_category.dart. @freezed class MeasurementCategory with _$MeasurementCategory { /// Inclusive upper bound for [name] diff --git a/lib/widgets/measurements/categories_card.dart b/lib/widgets/measurements/categories_card.dart index b778c9e28..4d6eb91d9 100644 --- a/lib/widgets/measurements/categories_card.dart +++ b/lib/widgets/measurements/categories_card.dart @@ -34,6 +34,7 @@ class CategoriesCard extends StatelessWidget { @override Widget build(BuildContext context) { + // sensibleRange() operates on raw (pre-aggregation) entries final (entriesAll, entries7dAvg) = sensibleRange( currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(), ); @@ -57,13 +58,14 @@ class CategoriesCard extends StatelessWidget { Container( padding: const EdgeInsets.all(10), height: 220, - child: MeasurementChartWidgetFl( + child: buildChartForMetricType( + currentCategory.metricType, entriesAll, + entries7dAvg, currentCategory.unit, - avgs: entries7dAvg, ), ), - if (entries7dAvg.isNotEmpty) + if (entries7dAvg.isNotEmpty && !currentCategory.metricType.isSummedPerDay) MeasurementOverallChangeWidget( entries7dAvg.first, entries7dAvg.last, diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 5abc04080..8bb7cf298 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -56,6 +56,7 @@ class EntriesList extends ConsumerWidget { plans, _category.unit, context, + metricType: _category.metricType, ), SizedBox( height: 300, diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 4a605ca92..5b9583084 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -95,6 +95,20 @@ class _MeasurementCategoryFormState extends ConsumerState DropdownMenuItem(value: t, child: Text(t.name))) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _draft = _draft.copyWith(metricType: value); + }); + } + }, + ), FormSubmitButton( label: AppLocalizations.of(context).save, onPressed: () async { From 6c3373fc73aed41a3bd695b17a3039c326c68b66 Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:18:12 +0545 Subject: [PATCH 14/22] feat(measurements): add metricType to category schema - Add metricType property to MeasurementCategory model and DB table. - Include local-only persistence for metric categorization. - Update UI form and localization strings. --- lib/database/powersync/tables/measurements.dart | 2 ++ lib/l10n/app_en.arb | 4 ++++ lib/models/measurements/measurement_category.dart | 1 + lib/widgets/measurements/forms.dart | 2 ++ 4 files changed, 9 insertions(+) diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index d0e386f8f..fe39506b1 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -31,6 +31,7 @@ class MeasurementCategoryTable extends Table { TextColumn get name => text()(); TextColumn get unit => text()(); + TextColumn get metricType => text().named('metric_type').withDefault(const Constant('custom'))(); } const PowersyncMeasurementCategoryTable = ps.Table( @@ -38,6 +39,7 @@ const PowersyncMeasurementCategoryTable = ps.Table( [ ps.Column.text('name'), ps.Column.text('unit'), + // ps.Column.text('metric_type') ], ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4aa2d492f..0dc21f14d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -542,6 +542,10 @@ }, "measurementCategoriesHelpText": "Measurement category, such as 'biceps' or 'body fat'", "@measurementCategoriesHelpText": {}, + "metricType": "Metric Type", + "@metricType": { + "description": "Metric type for a measurement category" + }, "metricCustom": "Custom", "@metricCustom": { "description": "Label for custom metric type" diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index 01a4c1cc1..f2eeb8696 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -122,6 +122,7 @@ class MeasurementCategory with _$MeasurementCategory { id: id != null ? Value(id!) : const Value.absent(), name: Value(name), unit: Value(unit), + metricType: Value(metricType.wireValue) ); } } diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 5b9583084..4f2cb6296 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -98,6 +98,8 @@ class _MeasurementCategoryFormState extends ConsumerState DropdownMenuItem(value: t, child: Text(t.name))) .toList(), From 5ff9d259d3f2de8eea7be4bf9be6ed3fefcac50d Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:38:35 +0545 Subject: [PATCH 15/22] feat(charts): add smoothed trendline to measurement charts - Implement centered moving average trendline in charts.dart. - Update MeasurementChartWidgetFl to render trendline bar data. - Integrate smoothedTreadline logic into metric chart builder. refactor(db): implement MetricType Drift converter - Add MeasurementMetricTypeConverter to map enum to SQLite text. - Update MeasurementCategoryTable to use the new converter. --- .../measurement_metric_type_converter.dart | 31 +++++++++++++ lib/database/powersync/database.dart | 1 + .../powersync/tables/measurements.dart | 9 ++-- .../measurements/measurement_category.dart | 2 +- lib/widgets/measurements/charts.dart | 46 ++++++++++++++++++- lib/widgets/measurements/forms.dart | 2 +- lib/widgets/measurements/helpers.dart | 12 +++++ 7 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 lib/database/converters/measurement_metric_type_converter.dart diff --git a/lib/database/converters/measurement_metric_type_converter.dart b/lib/database/converters/measurement_metric_type_converter.dart new file mode 100644 index 000000000..45105a269 --- /dev/null +++ b/lib/database/converters/measurement_metric_type_converter.dart @@ -0,0 +1,31 @@ +/* + * This file is part of wger Workout Manager . + * 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. + * + * 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:drift/drift.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; + +/// Maps a [MetricType] to and from the SQLite string format. +class MeasurementMetricTypeConverter extends TypeConverter { + const MeasurementMetricTypeConverter(); + + @override + MetricType fromSql(String fromDb) => MetricType.fromWire(fromDb); + + @override + String toSql(MetricType value) => value.wireValue; +} diff --git a/lib/database/powersync/database.dart b/lib/database/powersync/database.dart index a4292a286..caa3ed586 100644 --- a/lib/database/powersync/database.dart +++ b/lib/database/powersync/database.dart @@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:powersync/powersync.dart' as ps; import 'package:wger/database/converters/date_only_text_converter.dart'; import 'package:wger/database/converters/exercise_image_style_converter.dart'; +import 'package:wger/database/converters/measurement_metric_type_converter.dart'; import 'package:wger/database/converters/time_of_day_converter.dart'; import 'package:wger/database/converters/utc_datetime_converter.dart'; import 'package:wger/database/converters/workout_impression_converter.dart'; diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index fe39506b1..1f5c5063f 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -18,6 +18,7 @@ import 'package:drift/drift.dart'; import 'package:powersync/powersync.dart' as ps; +import 'package:wger/database/converters/measurement_metric_type_converter.dart'; import 'package:wger/database/converters/utc_datetime_converter.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; @@ -31,16 +32,12 @@ class MeasurementCategoryTable extends Table { TextColumn get name => text()(); TextColumn get unit => text()(); - TextColumn get metricType => text().named('metric_type').withDefault(const Constant('custom'))(); + TextColumn get metricType => text().map(const MeasurementMetricTypeConverter())(); } const PowersyncMeasurementCategoryTable = ps.Table( 'measurements_category', - [ - ps.Column.text('name'), - ps.Column.text('unit'), - // ps.Column.text('metric_type') - ], + [ps.Column.text('name'), ps.Column.text('unit'), ps.Column.text('metric_type')], ); @UseRowClass(MeasurementEntry) diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index f2eeb8696..5c3fbeeaa 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -122,7 +122,7 @@ class MeasurementCategory with _$MeasurementCategory { id: id != null ? Value(id!) : const Value.absent(), name: Value(name), unit: Value(unit), - metricType: Value(metricType.wireValue) + metricType: Value(metricType), ); } } diff --git a/lib/widgets/measurements/charts.dart b/lib/widgets/measurements/charts.dart index 049b68e3e..94b76f3d2 100644 --- a/lib/widgets/measurements/charts.dart +++ b/lib/widgets/measurements/charts.dart @@ -55,9 +55,10 @@ String weightUnit(bool isMetric, BuildContext context) { class MeasurementChartWidgetFl extends StatefulWidget { final List _entries; final List? avgs; + final List? trend; final String _unit; - const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs}); + const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs, this.trend}); @override State createState() => _MeasurementChartWidgetFlState(); @@ -212,6 +213,17 @@ class _MeasurementChartWidgetFlState extends State { barWidth: 1, dotData: const FlDotData(show: false), ), + if (widget.trend != null && widget.trend!.isNotEmpty) + LineChartBarData( + spots: widget.trend! + .map((e) => FlSpot(e.date.millisecondsSinceEpoch.toDouble(), e.value.toDouble())) + .toList(), + isCurved: true, + curveSmoothness: 0.4, + color: Theme.of(context).colorScheme.secondary, + barWidth: 3, + dotData: const FlDotData(show: false), + ), ], ); } @@ -382,6 +394,38 @@ class _MeasurementBarChartWidgetFlState extends State smoothedTrendline( + List vals, { + // int windowDays = 14, + int period = 10, +}) { + if (vals.isEmpty) { + return []; + } + + final sorted = [...vals]..sort((a, b) => a.date.compareTo(b.date)); + final List out = []; + + // Seed the initialization layer with the first data point value + double currentEma = sorted[0].value.toDouble(); + final double smoothing = 2 / (period + 1); + + for (int i = 0; i < sorted.length; i++) { + final point = sorted[i]; + + if (i > 0) { + // EMA equation: point.weight * smoothing + ema * (1 - smoothing) + currentEma = (point.value * smoothing) + (currentEma * (1 - smoothing)); + } + + out.add(MeasurementChartEntry(currentEma, point.date)); + } + + return out; +} + class Indicator extends StatelessWidget { const Indicator({ super.key, diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 4f2cb6296..0e928b019 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -101,7 +101,7 @@ class _MeasurementCategoryFormState extends ConsumerState DropdownMenuItem(value: t, child: Text(t.name))) + .map((t) => DropdownMenuItem(value: t, child: Text(t.localized(context)))) .toList(), onChanged: (value) { if (value != null) { diff --git a/lib/widgets/measurements/helpers.dart b/lib/widgets/measurements/helpers.dart index 9086f568d..a62c4dd1b 100644 --- a/lib/widgets/measurements/helpers.dart +++ b/lib/widgets/measurements/helpers.dart @@ -95,6 +95,14 @@ List getOverviewWidgetsSeries( text: AppLocalizations.of(context).indicatorAvg, isSquare: true, ), + // if (!metricType.isSummedPerDay && !metricType.isRangeType) + if (!metricType.isSummedPerDay) + Indicator( + color: Theme.of(context).colorScheme.secondary, + text: 'trend', + // text: AppLocalizations.of(context).indicatorTrend, + isSquare: true, + ), ], ), ]; @@ -136,9 +144,13 @@ Widget buildChartForMetricType( if (metricType.isSummedPerDay) { return MeasurementBarChartWidgetFl(aggregatePerDay(raw), unit); } + // if (metricType.isRangeType) { + // return buildRangeChartForMetricType(metricType, raw, unit); + // } return MeasurementChartWidgetFl( raw, unit, avgs: avg, + trend: smoothedTrendline(raw), ); } From 42507844d251e05a088e4fa1302d198690c326a3 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 9 Jul 2026 17:12:51 +0200 Subject: [PATCH 16/22] Cleanup, translations and small tests --- .../measurement_metric_type_converter.dart | 11 +- lib/database/powersync/database.g.dart | 17 +-- .../powersync/tables/measurements.dart | 2 +- lib/features/health/models/health_metric.dart | 4 +- .../health/providers/health_repository.dart | 5 +- .../health/providers/health_sync.dart | 6 +- .../models/measurement_entry.dart | 6 +- .../models/measurement_entry.freezed.dart | 4 +- lib/features/measurements/widgets/charts.dart | 6 +- .../measurements/widgets/helpers.dart | 4 +- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + test/core/validators_test.mocks.dart | 11 ++ .../health/providers/health_sync_test.dart | 5 +- .../models/measurement_category_test.dart | 30 +++++ .../measurements/widgets/charts_test.dart | 105 ++++++++++++++++++ 18 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 test/features/measurements/widgets/charts_test.dart diff --git a/lib/database/converters/measurement_metric_type_converter.dart b/lib/database/converters/measurement_metric_type_converter.dart index 0a22916bd..da716261c 100644 --- a/lib/database/converters/measurement_metric_type_converter.dart +++ b/lib/database/converters/measurement_metric_type_converter.dart @@ -20,17 +20,12 @@ import 'package:drift/drift.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; /// Maps a [MetricType] to and from the SQLite string format. -/// -/// The SQL side is nullable: the server does not persist `metric_type` yet, so a -/// category synced back down arrives without it. A missing value reads as -/// [MetricType.custom] rather than crashing the non-nullable model field. -class MeasurementMetricTypeConverter extends TypeConverter { +class MeasurementMetricTypeConverter extends TypeConverter { const MeasurementMetricTypeConverter(); @override - MetricType fromSql(String? fromDb) => - fromDb == null ? MetricType.custom : MetricType.fromWire(fromDb); + MetricType fromSql(String fromDb) => MetricType.fromWire(fromDb); @override - String? toSql(MetricType value) => value.wireValue; + String toSql(MetricType value) => value.wireValue; } diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index 4de3c2b82..473084017 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -4885,9 +4885,9 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable GeneratedColumn( 'metric_type', aliasedName, - true, + false, type: DriftSqlType.string, - requiredDuringInsert: false, + requiredDuringInsert: true, ).withConverter( $MeasurementCategoryTableTable.$convertermetricType, ); @@ -4949,7 +4949,7 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}metric_type'], - ), + )!, ), ); } @@ -4959,7 +4959,7 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable return $MeasurementCategoryTableTable(attachedDatabase, alias); } - static TypeConverter $convertermetricType = + static TypeConverter $convertermetricType = const MeasurementMetricTypeConverter(); } @@ -4980,10 +4980,11 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion custom({ Expression? id, Expression? name, @@ -16474,7 +16475,7 @@ typedef $$MeasurementCategoryTableTableCreateCompanionBuilder = Value id, required String name, required String unit, - Value metricType, + required MetricType metricType, Value rowid, }); typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = @@ -16706,7 +16707,7 @@ class $$MeasurementCategoryTableTableTableManager Value id = const Value.absent(), required String name, required String unit, - Value metricType = const Value.absent(), + required MetricType metricType, Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion.insert( id: id, diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index e28069e22..b59893272 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -33,7 +33,7 @@ class MeasurementCategoryTable extends Table { TextColumn get name => text()(); TextColumn get unit => text()(); TextColumn get metricType => - text().named('metric_type').nullable().map(const MeasurementMetricTypeConverter())(); + text().named('metric_type').map(const MeasurementMetricTypeConverter())(); } const PowersyncMeasurementCategoryTable = ps.Table( diff --git a/lib/features/health/models/health_metric.dart b/lib/features/health/models/health_metric.dart index b4615430f..a4afdc9a4 100644 --- a/lib/features/health/models/health_metric.dart +++ b/lib/features/health/models/health_metric.dart @@ -42,8 +42,8 @@ class HealthMetric { final HealthDataType dataType; /// Stable, non-localized category name. Used to find-or-create the category, - /// and as the fallback match after a sync round-trip resets [metricType] to - /// [MetricType.custom] (the server does not persist it yet). + /// and as the fallback match for a category the user already created by hand + /// (which carries [MetricType.custom], not this metric's type). final String canonicalName; /// Unit the value is stored in on the category. diff --git a/lib/features/health/providers/health_repository.dart b/lib/features/health/providers/health_repository.dart index 942d68d07..95c6e8a6c 100644 --- a/lib/features/health/providers/health_repository.dart +++ b/lib/features/health/providers/health_repository.dart @@ -35,8 +35,9 @@ class HealthRepository { final Health _health; final _logger = Logger('HealthRepository'); - /// Identifies which platform a reading came from. - String get sourceName => Platform.isIOS ? 'apple_health' : 'health_connect'; + /// The measurement `source` value for readings from this platform: `apple` + /// for Apple Health, `google` for Health Connect. + String get sourceName => Platform.isIOS ? 'apple' : 'google'; /// Whether a health platform is usable on this device. Future isAvailable() async { diff --git a/lib/features/health/providers/health_sync.dart b/lib/features/health/providers/health_sync.dart index 6cf3c74a6..f0a5d1b74 100644 --- a/lib/features/health/providers/health_sync.dart +++ b/lib/features/health/providers/health_sync.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2026 wger Team + * Copyright (c) 2026 - 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 @@ -188,8 +188,8 @@ class HealthSyncNotifier extends _$HealthSyncNotifier { } /// Finds the category for [metric] (by `metric_type`, falling back to the - /// canonical name once the server has nulled `metric_type` on a round-trip) - /// or creates it. The created category is appended to [categories] so a later + /// canonical name to reuse a matching category the user created by hand) or + /// creates it. The created category is appended to [categories] so a later /// metric in the same run reuses it. Future _findOrCreateCategory( HealthMetric metric, diff --git a/lib/features/measurements/models/measurement_entry.dart b/lib/features/measurements/models/measurement_entry.dart index 717408547..0f0b154cf 100644 --- a/lib/features/measurements/models/measurement_entry.dart +++ b/lib/features/measurements/models/measurement_entry.dart @@ -43,8 +43,8 @@ class MeasurementEntry with _$MeasurementEntry { @override final String notes; - /// Where the reading came from: `manual` or a health platform - /// (`apple_health`, `health_connect`). + /// Origin of the reading; one of the server's `source` values + /// (`user`, `google`, `apple`). @override final String source; @@ -59,7 +59,7 @@ class MeasurementEntry with _$MeasurementEntry { required this.date, required this.value, required this.notes, - this.source = 'manual', + this.source = 'user', this.externalId, }); diff --git a/lib/features/measurements/models/measurement_entry.freezed.dart b/lib/features/measurements/models/measurement_entry.freezed.dart index 4f0b855f7..84bb4fea5 100644 --- a/lib/features/measurements/models/measurement_entry.freezed.dart +++ b/lib/features/measurements/models/measurement_entry.freezed.dart @@ -15,8 +15,8 @@ T _$identity(T value) => value; mixin _$MeasurementEntry { /// Client-generated UUID, is `null` only before the first persist - String? get id; String get categoryId; DateTime get date; num get value; String get notes;/// Where the reading came from: `manual` or a health platform -/// (`apple_health`, `health_connect`). + String? get id; String get categoryId; DateTime get date; num get value; String get notes;/// Origin of the reading; one of the server's `source` values +/// (`user`, `google`, `apple`). String get source;/// Platform record UUID, used to deduplicate re-imports. `null` for manual /// entries. String? get externalId; diff --git a/lib/features/measurements/widgets/charts.dart b/lib/features/measurements/widgets/charts.dart index e9738c97e..8d43749f5 100644 --- a/lib/features/measurements/widgets/charts.dart +++ b/lib/features/measurements/widgets/charts.dart @@ -392,11 +392,11 @@ class _MeasurementBarChartWidgetFlState extends State smoothedTrendline( List vals, { - // int windowDays = 14, int period = 10, }) { if (vals.isEmpty) { diff --git a/lib/features/measurements/widgets/helpers.dart b/lib/features/measurements/widgets/helpers.dart index d4e9d7f34..456282c93 100644 --- a/lib/features/measurements/widgets/helpers.dart +++ b/lib/features/measurements/widgets/helpers.dart @@ -95,12 +95,10 @@ List getOverviewWidgetsSeries( text: AppLocalizations.of(context).indicatorAvg, isSquare: true, ), - // if (!metricType.isSummedPerDay && !metricType.isRangeType) if (!metricType.isSummedPerDay) Indicator( color: Theme.of(context).colorScheme.secondary, - text: 'trend', - // text: AppLocalizations.of(context).indicatorTrend, + text: AppLocalizations.of(context).indicatorTrend, isSquare: true, ), ], diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 84bb6786b..1204cf164 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -837,6 +837,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "Trend", "themeMode": "Theme Modus", "lightMode": "Immer Hellmodus", "systemMode": "Systemeinstellungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8a7b10988..4e45bb7d6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1318,6 +1318,10 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "trend", + "@indicatorTrend": { + "description": "Legend label for the smoothed trendline on measurement charts" + }, "endWorkout": "End workout", "@endWorkout": { "description": "Use the imperative, label on button to finish the current workout in gym mode" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f7063822d..7ec8167a6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -838,6 +838,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "tendencia", "themeMode": "Modo del tema", "darkMode": "Siempre en modo oscuro", "lightMode": "Siempre en modo claro", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 13406e317..0f1c4343e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -761,6 +761,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "tendance", "resistance_band": "Bande de résistance", "@resistance_band": { "description": "Generated entry for translation for server strings" diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index c751b9960..067e74602 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -4261,6 +4261,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get indicatorTrend => + (super.noSuchMethod( + Invocation.getter(#indicatorTrend), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#indicatorTrend), + ), + ) + as String); + @override String get endWorkout => (super.noSuchMethod( diff --git a/test/features/health/providers/health_sync_test.dart b/test/features/health/providers/health_sync_test.dart index c304730e0..2f3b66864 100644 --- a/test/features/health/providers/health_sync_test.dart +++ b/test/features/health/providers/health_sync_test.dart @@ -64,7 +64,7 @@ void main() { measurements = MockMeasurementRepository(); when(health.ensureAuthorized(any)).thenAnswer((_) async => true); - when(health.sourceName).thenReturn('apple_health'); + when(health.sourceName).thenReturn('apple'); when(measurements.addLocalDrift(any)).thenAnswer((_) async {}); when(measurements.addLocalDriftCategory(any)).thenAnswer((_) async {}); }); @@ -123,7 +123,7 @@ void main() { final bodyFat = entries.firstWhere((e) => e.externalId == 'bf-1'); expect(bodyFat.value, closeTo(20, 0.001)); // fraction -> percent - expect(bodyFat.source, 'apple_health'); + expect(bodyFat.source, 'apple'); final height = entries.firstWhere((e) => e.externalId == 'h-1'); expect(height.value, closeTo(180, 0.001)); // meters -> cm @@ -142,7 +142,6 @@ void main() { date: DateTime(2026, 1, 1), value: 20, notes: '', - source: 'apple_health', externalId: 'bf-1', ), ], diff --git a/test/features/measurements/models/measurement_category_test.dart b/test/features/measurements/models/measurement_category_test.dart index e76820418..1f0619fe8 100644 --- a/test/features/measurements/models/measurement_category_test.dart +++ b/test/features/measurements/models/measurement_category_test.dart @@ -43,4 +43,34 @@ void main() { expect(() => category.findEntryById('abc'), throwsA(isA())); }); }); + + group('MetricType', () { + test('fromWire maps a known wire value to its enum case', () { + expect(MetricType.fromWire('body_fat'), MetricType.bodyFat); + expect(MetricType.fromWire('blood_pressure'), MetricType.bloodPressure); + expect(MetricType.fromWire('custom'), MetricType.custom); + }); + + test('fromWire defaults to custom for an unknown value', () { + expect(MetricType.fromWire('something_new'), MetricType.custom); + expect(MetricType.fromWire(''), MetricType.custom); + }); + + test('wireValue round-trips through fromWire for every case', () { + for (final type in MetricType.values) { + expect(MetricType.fromWire(type.wireValue), type); + } + }); + + test('isSummedPerDay is true only for cumulative daily metrics', () { + expect(MetricType.steps.isSummedPerDay, isTrue); + expect(MetricType.distance.isSummedPerDay, isTrue); + expect(MetricType.energy.isSummedPerDay, isTrue); + expect(MetricType.sleep.isSummedPerDay, isTrue); + + expect(MetricType.custom.isSummedPerDay, isFalse); + expect(MetricType.bodyWeight.isSummedPerDay, isFalse); + expect(MetricType.heartRate.isSummedPerDay, isFalse); + }); + }); } diff --git a/test/features/measurements/widgets/charts_test.dart b/test/features/measurements/widgets/charts_test.dart new file mode 100644 index 000000000..0043acfc3 --- /dev/null +++ b/test/features/measurements/widgets/charts_test.dart @@ -0,0 +1,105 @@ +/* + * 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:flutter_test/flutter_test.dart'; +import 'package:wger/features/measurements/widgets/charts.dart'; + +void main() { + MeasurementChartEntry entry(num value, DateTime date) => MeasurementChartEntry(value, date); + + group('aggregatePerDay', () { + test('returns an empty list for no entries', () { + expect(aggregatePerDay([]), isEmpty); + }); + + test('sums entries sharing a calendar day into one point', () { + final result = aggregatePerDay([ + entry(1000, DateTime(2026, 1, 1, 8)), + entry(2500, DateTime(2026, 1, 1, 20)), + ]); + + expect(result.length, 1); + expect(result.single.value, 3500); + // time-of-day is stripped to the day boundary + expect(result.single.date, DateTime(2026, 1, 1)); + }); + + test('keeps separate days apart and sorts them ascending', () { + final result = aggregatePerDay([ + entry(30, DateTime(2026, 1, 3)), + entry(10, DateTime(2026, 1, 1, 6)), + entry(5, DateTime(2026, 1, 1, 23)), + entry(20, DateTime(2026, 1, 2)), + ]); + + expect(result.map((e) => e.date), [ + DateTime(2026, 1, 1), + DateTime(2026, 1, 2), + DateTime(2026, 1, 3), + ]); + expect(result.map((e) => e.value), [15, 20, 30]); + }); + }); + + group('smoothedTrendline', () { + test('returns an empty list for no entries', () { + expect(smoothedTrendline([]), isEmpty); + }); + + test('seeds the first point with the raw value and keeps the dates', () { + final input = [ + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + entry(30, DateTime(2026, 1, 3)), + ]; + final result = smoothedTrendline(input, period: 10); + + expect(result.length, 3); + expect(result.first.value, 10); // seed == first raw value + expect(result.map((e) => e.date), input.map((e) => e.date)); + }); + + test('EMA follows a rising series but lags behind the raw values', () { + final result = smoothedTrendline([ + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + entry(30, DateTime(2026, 1, 3)), + ], period: 10); + + // smoothing k = 2 / (10 + 1) ~= 0.1818 + // point 2: 20*k + 10*(1-k) = 11.818... + expect(result[1].value, closeTo(11.818, 0.001)); + // point 3: 30*k + 11.818*(1-k) = 15.123... + expect(result[2].value, closeTo(15.123, 0.001)); + // trend trails the raw climb + expect(result[2].value, lessThan(30)); + }); + + test('sorts unordered input by date before smoothing', () { + final result = smoothedTrendline([ + entry(30, DateTime(2026, 1, 3)), + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + ], period: 10); + + expect(result.first.date, DateTime(2026, 1, 1)); + expect(result.first.value, 10); + expect(result.last.date, DateTime(2026, 1, 3)); + }); + }); +} From 8aac3da7a8bc3a5d486c86259a938e7f35f6e4c0 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 10 Jul 2026 15:46:42 +0200 Subject: [PATCH 17/22] Add workaround for swift package manager for the health package --- ios/Flutter/Debug.xcconfig | 1 - ios/Flutter/Release.xcconfig | 1 - ios/Runner.xcodeproj/project.pbxproj | 14 +++---------- lib/features/health/models/health_metric.dart | 2 +- .../health/models/health_reading.dart | 2 +- .../health/providers/health_repository.dart | 2 +- .../health/providers/health_sync.dart | 2 +- pubspec.lock | 21 +++++++------------ pubspec.yaml | 10 ++++++++- .../health/providers/health_sync_test.dart | 2 +- .../providers/health_sync_test.mocks.dart | 2 +- 11 files changed, 25 insertions(+), 34 deletions(-) diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index ec97fc6f3..592ceee85 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index c4855bfe2..592ceee85 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 08bd47e13..006be35e8 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 */ @@ -295,7 +287,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -383,7 +375,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -432,7 +424,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/lib/features/health/models/health_metric.dart b/lib/features/health/models/health_metric.dart index a4afdc9a4..a1e48102a 100644 --- a/lib/features/health/models/health_metric.dart +++ b/lib/features/health/models/health_metric.dart @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import 'package:health/health.dart'; +import 'package:health_bridge/health.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; /// A body metric that can be imported from Apple Health / Health Connect into a diff --git a/lib/features/health/models/health_reading.dart b/lib/features/health/models/health_reading.dart index 1a6e52200..64101adf7 100644 --- a/lib/features/health/models/health_reading.dart +++ b/lib/features/health/models/health_reading.dart @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import 'package:health/health.dart'; +import 'package:health_bridge/health.dart'; /// A single reading pulled from a health platform, reduced to the fields the /// importer needs. Keeps the `health` package's [HealthDataPoint] out of the diff --git a/lib/features/health/providers/health_repository.dart b/lib/features/health/providers/health_repository.dart index 95c6e8a6c..c70d8032b 100644 --- a/lib/features/health/providers/health_repository.dart +++ b/lib/features/health/providers/health_repository.dart @@ -19,7 +19,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:health/health.dart'; +import 'package:health_bridge/health.dart'; import 'package:logging/logging.dart'; import 'package:wger/features/health/models/health_reading.dart'; diff --git a/lib/features/health/providers/health_sync.dart b/lib/features/health/providers/health_sync.dart index f0a5d1b74..b45c8edd0 100644 --- a/lib/features/health/providers/health_sync.dart +++ b/lib/features/health/providers/health_sync.dart @@ -17,7 +17,7 @@ */ import 'package:collection/collection.dart'; -import 'package:health/health.dart'; +import 'package:health_bridge/health.dart'; import 'package:logging/logging.dart'; import 'package:powersync/powersync.dart' as ps; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/pubspec.lock b/pubspec.lock index 76fb0255c..224fca8d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,14 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.2" - carp_serializable: - dependency: transitive - description: - name: carp_serializable - sha256: f039f8ea22e9437aef13fe7e9743c3761c76d401288dcb702eadd273c3e4dcef - url: "https://pub.dev" - source: hosted - version: "2.0.1" change: dependency: transitive description: @@ -743,14 +735,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - health: + health_bridge: dependency: "direct main" description: - name: health - sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524" - url: "https://pub.dev" - source: hosted - version: "13.3.1" + path: "." + ref: "51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1" + resolved-ref: "51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1" + url: "https://github.com/ytsni/health_bridge.git" + source: git + version: "1.0.0" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4567b8a03..8ddb61f9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,7 +51,15 @@ dependencies: flutter_zxing: ^2.3.0 font_awesome_flutter: ^11.0.0 freezed_annotation: ^3.1.0 - health: ^13.3.1 + # Temporarily swap `health` with a fork with Swift Package Manager support. + # Once that is available on upstream (see e.g. https://github.com/carp-dk/carp-health-flutter/issues/480 ) + # we can switch back. Note that if this doesn't happen in the near future we + # can just re-activate the cocoapods infrastructure. + # health: ^13.3.1 + health_bridge: + git: + url: https://github.com/ytsni/health_bridge.git + ref: 51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1 http: ^1.6.0 image_picker: ^1.2.2 intl: ^0.20.2 diff --git a/test/features/health/providers/health_sync_test.dart b/test/features/health/providers/health_sync_test.dart index 2f3b66864..5f94eaf73 100644 --- a/test/features/health/providers/health_sync_test.dart +++ b/test/features/health/providers/health_sync_test.dart @@ -18,7 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:health/health.dart'; +import 'package:health_bridge/health.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart index 938babcd9..0fde5d99f 100644 --- a/test/features/health/providers/health_sync_test.mocks.dart +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; -import 'package:health/health.dart' as _i5; +import 'package:health_bridge/health.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i3; import 'package:wger/features/health/models/health_reading.dart' as _i6; From 066d6430d870123a7d5662eb441d22e0ee13d65c Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 10 Jul 2026 16:18:48 +0200 Subject: [PATCH 18/22] Refactor the data model for multi-value categories Measurement categories can optionally point to another category, In practice, this will just be something like blood pressure since these values only make sense in a pair. Others can be interesting while combined (heart rate and steps), but are valid on their own. --- .../dashboard/widgets/measurements.dart | 5 +- lib/database/powersync/database.g.dart | 232 +++++++++++++++++- .../powersync/tables/measurements.dart | 19 +- .../models/measurement_category.dart | 24 ++ .../models/measurement_category.freezed.dart | 23 +- .../providers/measurement_notifier.dart | 6 + .../providers/measurement_notifier.g.dart | 2 +- .../providers/measurement_repository.dart | 34 ++- .../measurements/widgets/categories.dart | 16 +- .../measurements/widgets/categories_card.dart | 63 +++++ lib/features/measurements/widgets/forms.dart | 141 +++++++++++ lib/l10n/app_en.arb | 8 + test/core/validators_test.mocks.dart | 22 ++ .../providers/health_sync_test.mocks.dart | 11 + .../measurement_notifier_test.mocks.dart | 11 + .../measurement_repository_test.dart | 65 +++++ ...surement_categories_screen_test.mocks.dart | 11 + ...measurement_entries_screen_test.mocks.dart | 11 + .../screenshots_01_dashboard.mocks.dart | 11 + .../screenshots_04_measurements.mocks.dart | 11 + 20 files changed, 702 insertions(+), 24 deletions(-) diff --git a/lib/core/widgets/dashboard/widgets/measurements.dart b/lib/core/widgets/dashboard/widgets/measurements.dart index 75eabbcb6..a178bda56 100644 --- a/lib/core/widgets/dashboard/widgets/measurements.dart +++ b/lib/core/widgets/dashboard/widgets/measurements.dart @@ -45,7 +45,10 @@ class _DashboardMeasurementWidgetState extends ConsumerState>( value: ref.watch(measurementProvider), loggerName: 'DashboardMeasurementWidget', - data: (categoriesList) { + data: (allCategories) { + // Children of multi-value groups are shown inside their parent's card + final categoriesList = allCategories.where((c) => c.parentId == null).toList(); + if (categoriesList.isEmpty) { return NothingFound( AppLocalizations.of(context).moreMeasurementEntries, diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index 473084017..c01662f61 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -4891,8 +4891,39 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable ).withConverter( $MeasurementCategoryTableTable.$convertermetricType, ); + static const VerificationMeta _parentIdMeta = const VerificationMeta( + 'parentId', + ); + @override + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES measurements_category (id)', + ), + ); + static const VerificationMeta _orderMeta = const VerificationMeta('order'); @override - List get $columns => [id, name, unit, metricType]; + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + List get $columns => [ + id, + name, + unit, + metricType, + parentId, + order, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -4924,6 +4955,18 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable } else if (isInserting) { context.missing(_unitMeta); } + if (data.containsKey('parent_id')) { + context.handle( + _parentIdMeta, + parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta), + ); + } + if (data.containsKey('order')) { + context.handle( + _orderMeta, + order.isAcceptableOrUnknown(data['order']!, _orderMeta), + ); + } return context; } @@ -4951,6 +4994,14 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable data['${effectivePrefix}metric_type'], )!, ), + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, ); } @@ -4968,12 +5019,16 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion name; final Value unit; final Value metricType; + final Value parentId; + final Value order; final Value rowid; const MeasurementCategoryTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), this.unit = const Value.absent(), this.metricType = const Value.absent(), + this.parentId = const Value.absent(), + this.order = const Value.absent(), this.rowid = const Value.absent(), }); MeasurementCategoryTableCompanion.insert({ @@ -4981,6 +5036,8 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion? name, Expression? unit, Expression? metricType, + Expression? parentId, + Expression? order, Expression? rowid, }) { return RawValuesInsertable({ @@ -4997,6 +5056,8 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion? name, Value? unit, Value? metricType, + Value? parentId, + Value? order, Value? rowid, }) { return MeasurementCategoryTableCompanion( @@ -5013,6 +5076,8 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion(parentId.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -5049,6 +5120,8 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion parentId, + Value order, Value rowid, }); typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = @@ -16484,6 +16559,8 @@ typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = Value name, Value unit, Value metricType, + Value parentId, + Value order, Value rowid, }); @@ -16500,6 +16577,26 @@ final class $$MeasurementCategoryTableTableReferences super.$_typedResult, ); + static $MeasurementCategoryTableTable _parentIdTable( + _$DriftPowersyncDatabase db, + ) => db.measurementCategoryTable.createAlias( + 'measurements_category__parent_id__measurements_category__id', + ); + + $$MeasurementCategoryTableTableProcessedTableManager? get parentId { + final $_column = $_itemColumn('parent_id'); + if ($_column == null) return null; + final manager = $$MeasurementCategoryTableTableTableManager( + $_db, + $_db.measurementCategoryTable, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_parentIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + static MultiTypedResultKey<$MeasurementEntryTableTable, List> _measurementEntryTableRefsTable(_$DriftPowersyncDatabase db) => MultiTypedResultKey.fromTable( db.measurementEntryTable, @@ -16551,6 +16648,33 @@ class $$MeasurementCategoryTableTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnFilters get order => $composableBuilder( + column: $table.order, + builder: (column) => ColumnFilters(column), + ); + + $$MeasurementCategoryTableTableFilterComposer get parentId { + final $$MeasurementCategoryTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableFilterComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableFilterComposer f) f, ) { @@ -16604,6 +16728,33 @@ class $$MeasurementCategoryTableTableOrderingComposer column: $table.metricType, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get order => $composableBuilder( + column: $table.order, + builder: (column) => ColumnOrderings(column), + ); + + $$MeasurementCategoryTableTableOrderingComposer get parentId { + final $$MeasurementCategoryTableTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableOrderingComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } class $$MeasurementCategoryTableTableAnnotationComposer @@ -16629,6 +16780,31 @@ class $$MeasurementCategoryTableTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get order => + $composableBuilder(column: $table.order, builder: (column) => column); + + $$MeasurementCategoryTableTableAnnotationComposer get parentId { + final $$MeasurementCategoryTableTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableAnnotationComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableAnnotationComposer a) f, ) { @@ -16667,7 +16843,10 @@ class $$MeasurementCategoryTableTableTableManager $$MeasurementCategoryTableTableUpdateCompanionBuilder, (MeasurementCategory, $$MeasurementCategoryTableTableReferences), MeasurementCategory, - PrefetchHooks Function({bool measurementEntryTableRefs}) + PrefetchHooks Function({ + bool parentId, + bool measurementEntryTableRefs, + }) > { $$MeasurementCategoryTableTableTableManager( _$DriftPowersyncDatabase db, @@ -16694,12 +16873,16 @@ class $$MeasurementCategoryTableTableTableManager Value name = const Value.absent(), Value unit = const Value.absent(), Value metricType = const Value.absent(), + Value parentId = const Value.absent(), + Value order = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion( id: id, name: name, unit: unit, metricType: metricType, + parentId: parentId, + order: order, rowid: rowid, ), createCompanionCallback: @@ -16708,12 +16891,16 @@ class $$MeasurementCategoryTableTableTableManager required String name, required String unit, required MetricType metricType, + Value parentId = const Value.absent(), + Value order = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion.insert( id: id, name: name, unit: unit, metricType: metricType, + parentId: parentId, + order: order, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -16724,13 +16911,44 @@ class $$MeasurementCategoryTableTableTableManager ), ) .toList(), - prefetchHooksCallback: ({measurementEntryTableRefs = false}) { + prefetchHooksCallback: ({parentId = false, measurementEntryTableRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ if (measurementEntryTableRefs) db.measurementEntryTable, ], - addJoins: null, + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (parentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.parentId, + referencedTable: $$MeasurementCategoryTableTableReferences + ._parentIdTable(db), + referencedColumn: $$MeasurementCategoryTableTableReferences + ._parentIdTable(db) + .id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return [ if (measurementEntryTableRefs) @@ -16748,7 +16966,9 @@ class $$MeasurementCategoryTableTableTableManager p0, ).measurementEntryTableRefs, referencedItemsForCurrentItem: (item, referencedItems) => - referencedItems.where((e) => e.categoryId == item.id), + referencedItems.where( + (e) => e.categoryId == item.id, + ), typedResults: items, ), ]; @@ -16771,7 +16991,7 @@ typedef $$MeasurementCategoryTableTableProcessedTableManager = $$MeasurementCategoryTableTableUpdateCompanionBuilder, (MeasurementCategory, $$MeasurementCategoryTableTableReferences), MeasurementCategory, - PrefetchHooks Function({bool measurementEntryTableRefs}) + PrefetchHooks Function({bool parentId, bool measurementEntryTableRefs}) >; typedef $$MeasurementEntryTableTableCreateCompanionBuilder = MeasurementEntryTableCompanion Function({ diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index b59893272..cd16325a6 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -34,11 +34,28 @@ class MeasurementCategoryTable extends Table { TextColumn get unit => text()(); TextColumn get metricType => text().named('metric_type').map(const MeasurementMetricTypeConverter())(); + + /// Multi-value groups: parent category id (max. one level of nesting; only + /// leaf categories carry entries). + TextColumn get parentId => + text().named('parent_id').nullable().references(MeasurementCategoryTable, #id)(); + + /// Position in the category list; for children, the position within the group + IntColumn get order => integer().withDefault(const Constant(0))(); } const PowersyncMeasurementCategoryTable = ps.Table( 'measurements_category', - [ps.Column.text('name'), ps.Column.text('unit'), ps.Column.text('metric_type')], + [ + ps.Column.text('name'), + ps.Column.text('unit'), + ps.Column.text('metric_type'), + ps.Column.text('parent_id'), + ps.Column.integer('order'), + ], + indexes: [ + ps.Index('parent_idx', [ps.IndexedColumn('parent_id')]), + ], ); @UseRowClass(MeasurementEntry) diff --git a/lib/features/measurements/models/measurement_category.dart b/lib/features/measurements/models/measurement_category.dart index 47f72165f..24eca5553 100644 --- a/lib/features/measurements/models/measurement_category.dart +++ b/lib/features/measurements/models/measurement_category.dart @@ -103,14 +103,36 @@ class MeasurementCategory with _$MeasurementCategory { @override final MetricType metricType; + /// Multi-value groups (e.g. blood pressure): id of the parent category, one + /// child per component. Max. one level of nesting; only leaf categories + /// (no children) carry entries. + @override + final String? parentId; + + /// Position in the category list; for children, the position within the group + @override + final int order; + + /// Child categories (components) of this group. Populated by the repository + /// for display, never persisted directly. + @override + final List children; + MeasurementCategory({ this.id, this.name = '', this.unit = '', this.entries = const [], this.metricType = MetricType.custom, + this.parentId, + this.order = 0, + this.children = const [], }); + /// `true` for group parents (blood pressure etc.), which hold no entries of + /// their own + bool get isGroup => children.isNotEmpty; + MeasurementEntry findEntryById(String id) { return entries.firstWhere( (entry) => entry.id == id, @@ -125,6 +147,8 @@ class MeasurementCategory with _$MeasurementCategory { name: Value(name), unit: Value(unit), metricType: Value(metricType), + parentId: Value(parentId), + order: Value(order), ); } } diff --git a/lib/features/measurements/models/measurement_category.freezed.dart b/lib/features/measurements/models/measurement_category.freezed.dart index 215a5e72c..d5137c0bb 100644 --- a/lib/features/measurements/models/measurement_category.freezed.dart +++ b/lib/features/measurements/models/measurement_category.freezed.dart @@ -17,7 +17,13 @@ mixin _$MeasurementCategory { /// Client-generated UUID, is `null` only before the first persist String? get id; String get name; String get unit; List get entries;/// Drives the health-platform mapping (and, later, default unit/aggregation/ /// chart). [MetricType.custom] for plain user-created categories. - MetricType get metricType; + MetricType get metricType;/// Multi-value groups (e.g. blood pressure): id of the parent category, one +/// child per component. Max. one level of nesting; only leaf categories +/// (no children) carry entries. + String? get parentId;/// Position in the category list; for children, the position within the group + int get order;/// Child categories (components) of this group. Populated by the repository +/// for display, never persisted directly. + List get children; /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +34,16 @@ $MeasurementCategoryCopyWith get copyWith => _$MeasurementC @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)&&(identical(other.metricType, metricType) || other.metricType == metricType)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)&&(identical(other.metricType, metricType) || other.metricType == metricType)&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.order, order) || other.order == order)&&const DeepCollectionEquality().equals(other.children, children)); } @override -int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries),metricType); +int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries),metricType,parentId,order,const DeepCollectionEquality().hash(children)); @override String toString() { - return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries, metricType: $metricType)'; + return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries, metricType: $metricType, parentId: $parentId, order: $order, children: $children)'; } @@ -48,7 +54,7 @@ abstract mixin class $MeasurementCategoryCopyWith<$Res> { factory $MeasurementCategoryCopyWith(MeasurementCategory value, $Res Function(MeasurementCategory) _then) = _$MeasurementCategoryCopyWithImpl; @useResult $Res call({ - String? id, String name, String unit, List entries, MetricType metricType + String? id, String name, String unit, List entries, MetricType metricType, String? parentId, int order, List children }); @@ -65,14 +71,17 @@ class _$MeasurementCategoryCopyWithImpl<$Res> /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,Object? metricType = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,Object? metricType = null,Object? parentId = freezed,Object? order = null,Object? children = null,}) { return _then(MeasurementCategory( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable as String,entries: null == entries ? _self.entries : entries // ignore: cast_nullable_to_non_nullable as List,metricType: null == metricType ? _self.metricType : metricType // ignore: cast_nullable_to_non_nullable -as MetricType, +as MetricType,parentId: freezed == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/features/measurements/providers/measurement_notifier.dart b/lib/features/measurements/providers/measurement_notifier.dart index 8c8f6471f..eea5be3b3 100644 --- a/lib/features/measurements/providers/measurement_notifier.dart +++ b/lib/features/measurements/providers/measurement_notifier.dart @@ -72,6 +72,12 @@ final class MeasurementNotifier extends _$MeasurementNotifier { await _repo.addLocalDrift(entry); } + /// Adds one reading of a multi-value group (one entry per component, + /// persisted atomically). + Future addGroupEntries(List entries) async { + await _repo.addLocalDriftGroupEntries(entries); + } + // --- MeasurementCategory operations (delegated to repository) --- Future deleteCategory(String id) async { await _repo.deleteLocalDriftCategory(id); diff --git a/lib/features/measurements/providers/measurement_notifier.g.dart b/lib/features/measurements/providers/measurement_notifier.g.dart index 598a5a4c9..270cb246e 100644 --- a/lib/features/measurements/providers/measurement_notifier.g.dart +++ b/lib/features/measurements/providers/measurement_notifier.g.dart @@ -33,7 +33,7 @@ final class MeasurementNotifierProvider MeasurementNotifier create() => MeasurementNotifier(); } -String _$measurementNotifierHash() => r'94978ed841f94d3481322dc144ef82da95cfb62c'; +String _$measurementNotifierHash() => r'397b6739b5d49e226b1ca3bb1837b2f16c55e0f7'; abstract class _$MeasurementNotifier extends $StreamNotifier> { Stream> build(); diff --git a/lib/features/measurements/providers/measurement_repository.dart b/lib/features/measurements/providers/measurement_repository.dart index 06ce035fd..05b293ea7 100644 --- a/lib/features/measurements/providers/measurement_repository.dart +++ b/lib/features/measurements/providers/measurement_repository.dart @@ -39,7 +39,11 @@ class MeasurementRepository { MeasurementRepository(this._db); - /// Watches all categories, populated with their appropriate entries + /// Watches all categories, populated with their appropriate entries. + /// + /// The list is flat (children of multi-value groups appear as regular items + /// with a non-null `parentId`), but group parents additionally get their + /// [MeasurementCategory.children] attached, sorted by their in-group order. Stream> watchAll() { _logger.finer('Watching all measurement categories with entries'); @@ -50,6 +54,7 @@ class MeasurementRepository { _db.measurementEntryTable.categoryId.equalsExp(_db.measurementCategoryTable.id), ), ])..orderBy([ + OrderingTerm(expression: _db.measurementCategoryTable.order), OrderingTerm(expression: _db.measurementCategoryTable.name), OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), ]); @@ -71,9 +76,16 @@ class MeasurementRepository { } } - return map.values + final categories = map.values .map((c) => c.copyWith(entries: List.from(c.entries))) .toList(); + + // Attach children to their group parents (rows are already sorted by + // order/name, so insertion order is the display order). + return categories.map((c) { + final children = categories.where((other) => other.parentId == c.id).toList(); + return children.isEmpty ? c : c.copyWith(children: children); + }).toList(); }); } @@ -102,10 +114,26 @@ class MeasurementRepository { await _db.into(_db.measurementEntryTable).insert(entry.toCompanion()); } + /// Inserts one reading of a multi-value group: one entry per component, + /// written in a single transaction so a reading is never half-persisted. + Future addLocalDriftGroupEntries(List entries) async { + _logger.finer('Adding ${entries.length} local measurement entries for a group reading'); + await _db.transaction(() async { + for (final entry in entries) { + await _db.into(_db.measurementEntryTable).insert(entry.toCompanion()); + } + }); + } + // Categories Future deleteLocalDriftCategory(String id) async { _logger.finer('Deleting local measurement category $id'); - await (_db.delete(_db.measurementCategoryTable)..where((t) => t.id.equals(id))).go(); + await _db.transaction(() async { + // Children of a multi-value group are meaningless without their parent; + // the server cascades the same way. + await (_db.delete(_db.measurementCategoryTable)..where((t) => t.parentId.equals(id))).go(); + await (_db.delete(_db.measurementCategoryTable)..where((t) => t.id.equals(id))).go(); + }); } Future updateLocalDriftCategory(MeasurementCategory category) async { diff --git a/lib/features/measurements/widgets/categories.dart b/lib/features/measurements/widgets/categories.dart index 585f6a631..319d5ff84 100644 --- a/lib/features/measurements/widgets/categories.dart +++ b/lib/features/measurements/widgets/categories.dart @@ -31,11 +31,17 @@ class CategoriesList extends ConsumerWidget { return AsyncValueWidget>( value: ref.watch(measurementProvider), loggerName: 'CategoriesList', - data: (categoriesList) => ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: categoriesList.length, - itemBuilder: (context, index) => CategoriesCard(categoriesList[index]), - ), + data: (categoriesList) { + // Children of multi-value groups are rendered inside their parent's + // card, not as own list items + final topLevel = categoriesList.where((c) => c.parentId == null).toList(); + + return ListView.builder( + padding: const EdgeInsets.all(10.0), + itemCount: topLevel.length, + itemBuilder: (context, index) => CategoriesCard(topLevel[index]), + ); + }, ); } } diff --git a/lib/features/measurements/widgets/categories_card.dart b/lib/features/measurements/widgets/categories_card.dart index 47100bd00..3afff5ece 100644 --- a/lib/features/measurements/widgets/categories_card.dart +++ b/lib/features/measurements/widgets/categories_card.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:wger/core/form_screen.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; @@ -34,6 +35,10 @@ class CategoriesCard extends StatelessWidget { @override Widget build(BuildContext context) { + if (currentCategory.isGroup) { + return _buildGroupCard(context); + } + // sensibleRange() operates on raw (pre-aggregation) entries final (entriesAll, entries7dAvg) = sensibleRange( currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(), @@ -115,4 +120,62 @@ class CategoriesCard extends StatelessWidget { ), ); } + + /// Card for a multi-value group (e.g. blood pressure): one row per + /// component with its latest reading; new readings are entered for all + /// components at once. + Widget _buildGroupCard(BuildContext context) { + return Card( + elevation: elevation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + currentCategory.name, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ...currentCategory.children.map((child) { + // Entries arrive sorted by date descending, so first is the latest + final latest = child.entries.firstOrNull; + return ListTile( + dense: true, + title: Text(child.name), + trailing: Text( + latest != null ? '${latest.value} ${child.unit}' : '—', + style: Theme.of(context).textTheme.bodyLarge, + ), + onTap: () => Navigator.pushNamed( + context, + MeasurementEntriesScreen.routeName, + arguments: child.id, + ), + ); + }), + const Divider(), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () async { + await Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + GroupMeasurementEntryForm(currentCategory), + ), + ); + }, + icon: const Icon(Icons.add), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/measurements/widgets/forms.dart b/lib/features/measurements/widgets/forms.dart index 697a10169..18ab82446 100644 --- a/lib/features/measurements/widgets/forms.dart +++ b/lib/features/measurements/widgets/forms.dart @@ -111,6 +111,53 @@ class _MeasurementCategoryFormState extends ConsumerState c.parentId == _draft.id); + if (hasChildren) { + return const SizedBox.shrink(); + } + + final candidates = categories + .where((c) => c.parentId == null && c.id != _draft.id && c.entries.isEmpty) + .toList(); + if (candidates.isEmpty) { + return const SizedBox.shrink(); + } + + final initialParent = candidates.any((c) => c.id == _draft.parentId) + ? _draft.parentId + : null; + + return DropdownButtonFormField( + initialValue: initialParent, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).partOfGroup, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(AppLocalizations.of(context).noGroup), + ), + ...candidates.map( + (c) => DropdownMenuItem(value: c.id, child: Text(c.name)), + ), + ], + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(parentId: value); + }); + }, + ); + }, + ), FormSubmitButton( label: AppLocalizations.of(context).save, onPressed: () async { @@ -288,3 +335,97 @@ class _MeasurementEntryFormState extends ConsumerState { ); } } + +/// Entry form for a multi-value group (e.g. blood pressure): one value field +/// per component, saved as one entry per component with a shared timestamp. +class GroupMeasurementEntryForm extends ConsumerStatefulWidget { + final MeasurementCategory _group; + + const GroupMeasurementEntryForm(this._group); + + @override + ConsumerState createState() => _GroupMeasurementEntryFormState(); +} + +class _GroupMeasurementEntryFormState extends ConsumerState { + final _form = GlobalKey(); + + DateTime _date = DateTime.now(); + late final Map _values = { + for (final child in widget._group.children) child.id!: null, + }; + + @override + Widget build(BuildContext context) { + return Form( + key: _form, + child: Column( + children: [ + // Date and time are shared by all components of the reading + DateInputWidget( + value: _date, + labelText: AppLocalizations.of(context).date, + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + onChanged: (date) { + _date = _date.copyWith( + year: date.year, + month: date.month, + day: date.day, + ); + }, + ), + TimeInputWidget( + value: TimeOfDay.fromDateTime(_date), + labelText: AppLocalizations.of(context).time, + onChanged: (time) { + _date = _date.copyWith( + hour: time.hour, + minute: time.minute, + second: 0, + ); + }, + ), + + // One value field per component + for (final child in widget._group.children) + DecimalInputWidget( + value: _values[child.id], + labelText: child.name, + suffixText: child.unit.isNotEmpty ? child.unit : widget._group.unit, + isRequired: true, + min: MeasurementEntry.minValue, + max: MeasurementEntry.maxValue, + onChanged: (value) => _values[child.id!] = value, + ), + + FormSubmitButton( + label: AppLocalizations.of(context).save, + onPressed: () async { + if (!_form.currentState!.validate()) { + return; + } + _form.currentState!.save(); + + final entries = widget._group.children + .map( + (child) => MeasurementEntry( + categoryId: child.id!, + date: _date, + value: _values[child.id]!, + notes: '', + ), + ) + .toList(); + await ref.read(measurementProvider.notifier).addGroupEntries(entries); + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4e45bb7d6..c0b232432 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -588,6 +588,14 @@ }, "measurementEntriesHelpText": "The unit used to measure the category such as 'cm' or '%'", "@measurementEntriesHelpText": {}, + "partOfGroup": "Part of group", + "@partOfGroup": { + "description": "Dropdown label: makes a measurement category a component of a multi-value group (e.g. blood pressure)" + }, + "noGroup": "No group", + "@noGroup": { + "description": "Dropdown option for a measurement category that is not part of any group" + }, "date": "Date", "@date": { "description": "The date of a workout log or body weight entry" diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 067e74602..ac8462028 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -2151,6 +2151,28 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get partOfGroup => + (super.noSuchMethod( + Invocation.getter(#partOfGroup), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#partOfGroup), + ), + ) + as String); + + @override + String get noGroup => + (super.noSuchMethod( + Invocation.getter(#noGroup), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#noGroup), + ), + ) + as String); + @override String get date => (super.noSuchMethod( diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart index 0fde5d99f..db9fef171 100644 --- a/test/features/health/providers/health_sync_test.mocks.dart +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -146,6 +146,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i7.MeasurementRepos ) as _i4.Future); + @override + _i4.Future addLocalDriftGroupEntries( + List<_i9.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + @override _i4.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( diff --git a/test/features/measurements/providers/measurement_notifier_test.mocks.dart b/test/features/measurements/providers/measurement_notifier_test.mocks.dart index 5a502e6c9..baf6519c2 100644 --- a/test/features/measurements/providers/measurement_notifier_test.mocks.dart +++ b/test/features/measurements/providers/measurement_notifier_test.mocks.dart @@ -88,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( diff --git a/test/features/measurements/providers/measurement_repository_test.dart b/test/features/measurements/providers/measurement_repository_test.dart index 89a508cd5..36ec5ab7e 100644 --- a/test/features/measurements/providers/measurement_repository_test.dart +++ b/test/features/measurements/providers/measurement_repository_test.dart @@ -222,4 +222,69 @@ void main() { expect(emitted.map((c) => c.id), ['c2']); }); }); + + group('multi-value groups', () { + Future seedBloodPressureGroup() async { + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'bp', name: 'Blood pressure', unit: 'mmHg'), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'sys', name: 'Systolic', unit: 'mmHg', parentId: 'bp', order: 0), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'dia', name: 'Diastolic', unit: 'mmHg', parentId: 'bp', order: 1), + ); + } + + test('watchAll attaches children to their parent in group order', () async { + await seedBloodPressureGroup(); + + final emitted = await repo.watchAll().first; + + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.isGroup, isTrue); + expect(parent.children.map((c) => c.id), ['sys', 'dia']); + }); + + test('watchAll sorts categories by order before name', () async { + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c1', name: 'Aaa', unit: 'cm', order: 5), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c2', name: 'Zzz', unit: 'cm', order: 1), + ); + + final emitted = await repo.watchAll().first; + + expect(emitted.map((c) => c.id), ['c2', 'c1']); + }); + + test('addLocalDriftGroupEntries inserts one entry per component', () async { + await seedBloodPressureGroup(); + final date = DateTime.utc(2026, 7, 10, 14, 32); + + await repo.addLocalDriftGroupEntries([ + MeasurementEntry(id: 'e1', categoryId: 'sys', date: date, value: 120, notes: ''), + MeasurementEntry(id: 'e2', categoryId: 'dia', date: date, value: 80, notes: ''), + ]); + + final emitted = await repo.watchAll().first; + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.children.first.entries.single.value, 120); + expect(parent.children.last.entries.single.value, 80); + expect( + parent.children.first.entries.single.date, + parent.children.last.entries.single.date, + ); + }); + + test('deleteLocalDriftCategory removes children along with the parent', () async { + await seedBloodPressureGroup(); + + await repo.deleteLocalDriftCategory('bp'); + + final emitted = await repo.watchAll().first; + expect(emitted, isEmpty); + }); + }); } diff --git a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart index 91c79d7fe..8fa32a7e8 100644 --- a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart @@ -88,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( diff --git a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart index 15b6f34b1..c31c80f05 100644 --- a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart @@ -97,6 +97,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( diff --git a/test/screenshots/screenshots_01_dashboard.mocks.dart b/test/screenshots/screenshots_01_dashboard.mocks.dart index f5c2b54c0..f0af1a51a 100644 --- a/test/screenshots/screenshots_01_dashboard.mocks.dart +++ b/test/screenshots/screenshots_01_dashboard.mocks.dart @@ -509,6 +509,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo ) as _i10.Future); + @override + _i10.Future addLocalDriftGroupEntries( + List<_i25.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + @override _i10.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( diff --git a/test/screenshots/screenshots_04_measurements.mocks.dart b/test/screenshots/screenshots_04_measurements.mocks.dart index 686d60878..ab8aa659f 100644 --- a/test/screenshots/screenshots_04_measurements.mocks.dart +++ b/test/screenshots/screenshots_04_measurements.mocks.dart @@ -88,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( From 29032dec408027524f11c13e03e3df7e48768f02 Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:06:16 +0545 Subject: [PATCH 19/22] feat(measurements): add order field to MeasurementCategory model and drag-and-drop category sort screen - sort categories by order and implement reorder logic --- .../powersync/tables/measurements.dart | 8 ++- .../models/measurement_category.dart | 5 ++ .../providers/measurement_notifier.dart | 14 +++++ .../providers/measurement_repository.dart | 11 +++- .../measurement_categories_screen.dart | 16 ++++- .../measurement_category_sort_screen.dart | 59 +++++++++++++++++++ lib/l10n/app_en.arb | 4 ++ lib/main.dart | 2 + 8 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 lib/features/measurements/screens/measurement_category_sort_screen.dart diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index b59893272..7e6e0aee2 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -34,11 +34,17 @@ class MeasurementCategoryTable extends Table { TextColumn get unit => text()(); TextColumn get metricType => text().named('metric_type').map(const MeasurementMetricTypeConverter())(); + IntColumn get order => integer().nullable()(); } const PowersyncMeasurementCategoryTable = ps.Table( 'measurements_category', - [ps.Column.text('name'), ps.Column.text('unit'), ps.Column.text('metric_type')], + [ + ps.Column.text('name'), + ps.Column.text('unit'), + ps.Column.text('metric_type'), + ps.Column.integer('order'), + ], ); @UseRowClass(MeasurementEntry) diff --git a/lib/features/measurements/models/measurement_category.dart b/lib/features/measurements/models/measurement_category.dart index 47f72165f..3bf552ffd 100644 --- a/lib/features/measurements/models/measurement_category.dart +++ b/lib/features/measurements/models/measurement_category.dart @@ -103,12 +103,16 @@ class MeasurementCategory with _$MeasurementCategory { @override final MetricType metricType; + @override + final int order; + MeasurementCategory({ this.id, this.name = '', this.unit = '', this.entries = const [], this.metricType = MetricType.custom, + this.order = 0, }); MeasurementEntry findEntryById(String id) { @@ -125,6 +129,7 @@ class MeasurementCategory with _$MeasurementCategory { name: Value(name), unit: Value(unit), metricType: Value(metricType), + order: Value(order), ); } } diff --git a/lib/features/measurements/providers/measurement_notifier.dart b/lib/features/measurements/providers/measurement_notifier.dart index 8c8f6471f..59d8f4e6c 100644 --- a/lib/features/measurements/providers/measurement_notifier.dart +++ b/lib/features/measurements/providers/measurement_notifier.dart @@ -84,4 +84,18 @@ final class MeasurementNotifier extends _$MeasurementNotifier { Future addCategory(MeasurementCategory category) async { await _repo.addLocalDriftCategory(category); } + + Future setCategoryOrder(int oldIndex, int newIndex) async { + final categories = state.asData?.value; + if (categories == null) return; + if (oldIndex < newIndex) newIndex -= 1; + + final reordered = List.from(categories); + final moved = reordered.removeAt(oldIndex); + reordered.insert(newIndex, moved); + + for (int i = 0; i < reordered.length; i++) { + await _repo.reorderCategory(reordered[i].id!, i); + } + } } diff --git a/lib/features/measurements/providers/measurement_repository.dart b/lib/features/measurements/providers/measurement_repository.dart index 06ce035fd..55216cb19 100644 --- a/lib/features/measurements/providers/measurement_repository.dart +++ b/lib/features/measurements/providers/measurement_repository.dart @@ -50,8 +50,9 @@ class MeasurementRepository { _db.measurementEntryTable.categoryId.equalsExp(_db.measurementCategoryTable.id), ), ])..orderBy([ + OrderingTerm(expression: _db.measurementCategoryTable.order), OrderingTerm(expression: _db.measurementCategoryTable.name), - OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), + // OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), ]); return joined.watch().map((rows) { @@ -118,4 +119,12 @@ class MeasurementRepository { _logger.finer('Adding local measurement category ${category.name}'); await _db.into(_db.measurementCategoryTable).insert(category.toCompanion()); } + + Future reorderCategory(String id, int newOrder) async { + _logger.finer('Reording category id $id to order $newOrder'); + final stmt = _db.update(_db.measurementCategoryTable)..where((t) => t.id.equals(id)); + await stmt.write( + MeasurementCategoryTableCompanion(order: Value(newOrder)), + ); + } } diff --git a/lib/features/measurements/screens/measurement_categories_screen.dart b/lib/features/measurements/screens/measurement_categories_screen.dart index 985f020fd..abe8aa646 100644 --- a/lib/features/measurements/screens/measurement_categories_screen.dart +++ b/lib/features/measurements/screens/measurement_categories_screen.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:wger/core/form_screen.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; import 'package:wger/features/measurements/widgets/categories.dart'; import 'package:wger/features/measurements/widgets/forms.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -30,8 +31,19 @@ class MeasurementCategoriesScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context).measurements)), + appBar: AppBar( + title: Text(AppLocalizations.of(context).measurements), + actions: [ + IconButton( + icon: Icon(Icons.sort), + tooltip: i18n.reorderCategories, + onPressed: () => + Navigator.of(context).pushNamed(MeasurementCategorySortScreen.routeName), + ), + ], + ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add, color: Colors.white), onPressed: () { @@ -39,7 +51,7 @@ class MeasurementCategoriesScreen extends StatelessWidget { context, FormScreen.routeName, arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, + i18n.newEntry, const MeasurementCategoryForm(), ), ); diff --git a/lib/features/measurements/screens/measurement_category_sort_screen.dart b/lib/features/measurements/screens/measurement_category_sort_screen.dart new file mode 100644 index 000000000..cbf4ef071 --- /dev/null +++ b/lib/features/measurements/screens/measurement_category_sort_screen.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/core/widgets/async_value_widget.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/providers/measurement_notifier.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +class MeasurementCategorySortScreen extends ConsumerWidget { + static const routeName = '/measurement-categories-sort'; + + const MeasurementCategorySortScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final i18n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(i18n.reorderCategories), + ), + body: WidescreenWrapper( + child: AsyncValueWidget>( + value: ref.watch(measurementProvider), + loggerName: 'MeasurementCategorySortScreen', + data: (categories) => _SortableList(categories), + ), + ), + ); + } +} + +class _SortableList extends ConsumerWidget { + final List _categories; + const _SortableList(this._categories); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(measurementProvider.notifier); + return ReorderableListView.builder( + // physics: const NeverScrollableScrollPhysics(), + // shrinkWrap: true, + // buildDefaultDragHandles: false, + itemCount: _categories.length, + itemBuilder: (context, index) { + final category = _categories[index]; + return ListTile( + key: ValueKey(category.id), + title: Text(category.name), + subtitle: Text(category.unit), + trailing: ReorderableDelayedDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + ); + }, + onReorderItem: (oldIndex, newIndex) => notifier.setCategoryOrder(oldIndex, newIndex), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4e45bb7d6..90930f8f0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -546,6 +546,10 @@ "@metricType": { "description": "Metric type for a measurement category" }, + "reorderCategories": "Reorder categories", + "@reorderCategories": { + "description": "measurement categories reordering" + }, "metricCustom": "Custom", "@metricCustom": { "description": "Label for custom metric type" diff --git a/lib/main.dart b/lib/main.dart index 74d053ce9..2a5cb29ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,7 @@ import 'package:wger/features/exercises/screens/exercise_screen.dart'; import 'package:wger/features/exercises/screens/exercises_screen.dart'; import 'package:wger/features/gallery/screens/gallery_screen.dart'; import 'package:wger/features/measurements/screens/measurement_categories_screen.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; import 'package:wger/features/measurements/screens/measurement_entries_screen.dart'; import 'package:wger/features/nutrition/screens/ingredient_detail_screen.dart'; import 'package:wger/features/nutrition/screens/ingredients_screen.dart'; @@ -206,6 +207,7 @@ class MainApp extends ConsumerWidget { GymModeScreen.routeName: (ctx) => const GymModeScreen(), HomeTabsScreen.routeName: (ctx) => const HomeTabsScreen(), MeasurementCategoriesScreen.routeName: (ctx) => const MeasurementCategoriesScreen(), + MeasurementCategorySortScreen.routeName: (ctx) => const MeasurementCategorySortScreen(), MeasurementEntriesScreen.routeName: (ctx) => const MeasurementEntriesScreen(), NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), From cbb960bdef3054b1cea707fdfebf1b4260be1f5b Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 11 Jul 2026 00:31:35 +0200 Subject: [PATCH 20/22] Filter out sub-categories, fix off-by-one index --- .../providers/measurement_notifier.dart | 16 +- .../providers/measurement_notifier.g.dart | 2 +- .../providers/measurement_repository.dart | 20 ++- .../measurement_categories_screen.dart | 2 +- .../measurement_category_sort_screen.dart | 138 +++++++++-------- .../providers/health_sync_test.mocks.dart | 9 ++ .../providers/measurement_notifier_test.dart | 59 ++++++++ .../measurement_notifier_test.mocks.dart | 9 ++ .../measurement_repository_test.dart | 36 +++++ ...surement_categories_screen_test.mocks.dart | 9 ++ ...measurement_category_sort_screen_test.dart | 89 +++++++++++ ...ement_category_sort_screen_test.mocks.dart | 139 ++++++++++++++++++ ...measurement_entries_screen_test.mocks.dart | 9 ++ .../screenshots_01_dashboard.mocks.dart | 9 ++ .../screenshots_04_measurements.mocks.dart | 9 ++ 15 files changed, 481 insertions(+), 74 deletions(-) create mode 100644 test/features/measurements/screens/measurement_category_sort_screen_test.dart create mode 100644 test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart diff --git a/lib/features/measurements/providers/measurement_notifier.dart b/lib/features/measurements/providers/measurement_notifier.dart index 593020fd0..2f86c12ca 100644 --- a/lib/features/measurements/providers/measurement_notifier.dart +++ b/lib/features/measurements/providers/measurement_notifier.dart @@ -91,17 +91,21 @@ final class MeasurementNotifier extends _$MeasurementNotifier { await _repo.addLocalDriftCategory(category); } + /// Moves the top-level category at [oldIndex] to [newIndex] and renumbers + /// all top-level categories accordingly. + /// + /// Indices refer to the top-level list only (children of multi-value groups + /// keep their in-group order). Future setCategoryOrder(int oldIndex, int newIndex) async { final categories = state.asData?.value; - if (categories == null) return; - if (oldIndex < newIndex) newIndex -= 1; + if (categories == null) { + return; + } - final reordered = List.from(categories); + final reordered = categories.where((c) => c.parentId == null).toList(); final moved = reordered.removeAt(oldIndex); reordered.insert(newIndex, moved); - for (int i = 0; i < reordered.length; i++) { - await _repo.reorderCategory(reordered[i].id!, i); - } + await _repo.reorderCategories([for (final c in reordered) c.id!]); } } diff --git a/lib/features/measurements/providers/measurement_notifier.g.dart b/lib/features/measurements/providers/measurement_notifier.g.dart index 270cb246e..561817c74 100644 --- a/lib/features/measurements/providers/measurement_notifier.g.dart +++ b/lib/features/measurements/providers/measurement_notifier.g.dart @@ -33,7 +33,7 @@ final class MeasurementNotifierProvider MeasurementNotifier create() => MeasurementNotifier(); } -String _$measurementNotifierHash() => r'397b6739b5d49e226b1ca3bb1837b2f16c55e0f7'; +String _$measurementNotifierHash() => r'2072d9a5dd3b0c64830913541d236f5dfe28fbbc'; abstract class _$MeasurementNotifier extends $StreamNotifier> { Stream> build(); diff --git a/lib/features/measurements/providers/measurement_repository.dart b/lib/features/measurements/providers/measurement_repository.dart index ae2ed4559..fa24db21c 100644 --- a/lib/features/measurements/providers/measurement_repository.dart +++ b/lib/features/measurements/providers/measurement_repository.dart @@ -56,7 +56,7 @@ class MeasurementRepository { ])..orderBy([ OrderingTerm(expression: _db.measurementCategoryTable.order), OrderingTerm(expression: _db.measurementCategoryTable.name), - // OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), + OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), ]); return joined.watch().map((rows) { @@ -147,11 +147,17 @@ class MeasurementRepository { await _db.into(_db.measurementCategoryTable).insert(category.toCompanion()); } - Future reorderCategory(String id, int newOrder) async { - _logger.finer('Reording category id $id to order $newOrder'); - final stmt = _db.update(_db.measurementCategoryTable)..where((t) => t.id.equals(id)); - await stmt.write( - MeasurementCategoryTableCompanion(order: Value(newOrder)), - ); + /// Persists the given display order: each category gets its list index as + /// [MeasurementCategory.order]. Categories whose order is unchanged are not + /// written, so no sync upload is queued for them. + Future reorderCategories(List orderedIds) async { + _logger.finer('Reordering ${orderedIds.length} categories'); + await _db.transaction(() async { + for (var i = 0; i < orderedIds.length; i++) { + final stmt = _db.update(_db.measurementCategoryTable) + ..where((t) => t.id.equals(orderedIds[i]) & t.order.equals(i).not()); + await stmt.write(MeasurementCategoryTableCompanion(order: Value(i))); + } + }); } } diff --git a/lib/features/measurements/screens/measurement_categories_screen.dart b/lib/features/measurements/screens/measurement_categories_screen.dart index abe8aa646..51393e67b 100644 --- a/lib/features/measurements/screens/measurement_categories_screen.dart +++ b/lib/features/measurements/screens/measurement_categories_screen.dart @@ -37,7 +37,7 @@ class MeasurementCategoriesScreen extends StatelessWidget { title: Text(AppLocalizations.of(context).measurements), actions: [ IconButton( - icon: Icon(Icons.sort), + icon: const Icon(Icons.sort), tooltip: i18n.reorderCategories, onPressed: () => Navigator.of(context).pushNamed(MeasurementCategorySortScreen.routeName), diff --git a/lib/features/measurements/screens/measurement_category_sort_screen.dart b/lib/features/measurements/screens/measurement_category_sort_screen.dart index cbf4ef071..96675dce8 100644 --- a/lib/features/measurements/screens/measurement_category_sort_screen.dart +++ b/lib/features/measurements/screens/measurement_category_sort_screen.dart @@ -1,59 +1,79 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:wger/core/wide_screen_wrapper.dart'; -import 'package:wger/core/widgets/async_value_widget.dart'; -import 'package:wger/features/measurements/models/measurement_category.dart'; -import 'package:wger/features/measurements/providers/measurement_notifier.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; - -class MeasurementCategorySortScreen extends ConsumerWidget { - static const routeName = '/measurement-categories-sort'; - - const MeasurementCategorySortScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final i18n = AppLocalizations.of(context); - return Scaffold( - appBar: AppBar( - title: Text(i18n.reorderCategories), - ), - body: WidescreenWrapper( - child: AsyncValueWidget>( - value: ref.watch(measurementProvider), - loggerName: 'MeasurementCategorySortScreen', - data: (categories) => _SortableList(categories), - ), - ), - ); - } -} - -class _SortableList extends ConsumerWidget { - final List _categories; - const _SortableList(this._categories); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final notifier = ref.read(measurementProvider.notifier); - return ReorderableListView.builder( - // physics: const NeverScrollableScrollPhysics(), - // shrinkWrap: true, - // buildDefaultDragHandles: false, - itemCount: _categories.length, - itemBuilder: (context, index) { - final category = _categories[index]; - return ListTile( - key: ValueKey(category.id), - title: Text(category.name), - subtitle: Text(category.unit), - trailing: ReorderableDelayedDragStartListener( - index: index, - child: const Icon(Icons.drag_handle), - ), - ); - }, - onReorderItem: (oldIndex, newIndex) => notifier.setCategoryOrder(oldIndex, newIndex), - ); - } -} +/* + * 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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/core/widgets/async_value_widget.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/providers/measurement_notifier.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +/// Drag-and-drop reordering of the top-level measurement categories. +/// +/// Children of multi-value groups are not listed; they keep their in-group +/// order and follow their parent. +class MeasurementCategorySortScreen extends ConsumerWidget { + static const routeName = '/measurement-categories-sort'; + + const MeasurementCategorySortScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final i18n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(i18n.reorderCategories), + ), + body: WidescreenWrapper( + child: AsyncValueWidget>( + value: ref.watch(measurementProvider), + loggerName: 'MeasurementCategorySortScreen', + data: (categories) => _SortableList(categories.where((c) => c.parentId == null).toList()), + ), + ), + ); + } +} + +class _SortableList extends ConsumerWidget { + final List _categories; + const _SortableList(this._categories); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(measurementProvider.notifier); + return ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: _categories.length, + itemBuilder: (context, index) { + final category = _categories[index]; + return ListTile( + key: ValueKey(category.id), + title: Text(category.name), + subtitle: Text(category.unit), + trailing: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + ); + }, + onReorderItem: notifier.setCategoryOrder, + ); + } +} diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart index db9fef171..952330127 100644 --- a/test/features/health/providers/health_sync_test.mocks.dart +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -185,4 +185,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i7.MeasurementRepos returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } diff --git a/test/features/measurements/providers/measurement_notifier_test.dart b/test/features/measurements/providers/measurement_notifier_test.dart index f1987e09f..0075fb560 100644 --- a/test/features/measurements/providers/measurement_notifier_test.dart +++ b/test/features/measurements/providers/measurement_notifier_test.dart @@ -20,6 +20,7 @@ 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/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/providers/measurement_notifier.dart'; import 'package:wger/features/measurements/providers/measurement_repository.dart'; @@ -102,4 +103,62 @@ void main() { verify(mockRepo.watchAll()).called(1); }); }); + + group('setCategoryOrder', () { + // Body fat ('1') and Biceps ('2') from the shared test data plus a third + // top-level category. + final categories = [ + ...getMeasurementCategories(), + MeasurementCategory(id: '3', name: 'Waist', unit: 'cm'), + ]; + + setUp(() { + when(mockRepo.watchAll()).thenAnswer((_) => Stream.value(categories)); + when(mockRepo.reorderCategories(any)).thenAnswer((_) async {}); + }); + + // Loads the notifier with its state resolved from the watchAll stream. + Future loadedNotifier() async { + container.listen(measurementProvider, (_, _) {}); + await pumpEventQueue(); + return container.read(measurementProvider.notifier); + } + + test('moves an item down (newIndex already adjusted, onReorderItem semantics)', () async { + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 2); + verify(mockRepo.reorderCategories(['2', '3', '1'])).called(1); + }); + + test('moves an item up', () async { + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(2, 0); + verify(mockRepo.reorderCategories(['3', '1', '2'])).called(1); + }); + + test('excludes children of multi-value groups', () async { + when(mockRepo.watchAll()).thenAnswer( + (_) => Stream.value([ + categories[0], + MeasurementCategory(id: '2a', name: 'Left', unit: 'cm', parentId: '2'), + categories[1], + categories[2], + ]), + ); + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 1); + verify(mockRepo.reorderCategories(['2', '1', '3'])).called(1); + }); + + test('does nothing while the list is still loading', () async { + when(mockRepo.watchAll()).thenAnswer((_) => const Stream.empty()); + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 1); + verifyNever(mockRepo.reorderCategories(any)); + }); + }); } diff --git a/test/features/measurements/providers/measurement_notifier_test.mocks.dart b/test/features/measurements/providers/measurement_notifier_test.mocks.dart index baf6519c2..28cd63948 100644 --- a/test/features/measurements/providers/measurement_notifier_test.mocks.dart +++ b/test/features/measurements/providers/measurement_notifier_test.mocks.dart @@ -127,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } diff --git a/test/features/measurements/providers/measurement_repository_test.dart b/test/features/measurements/providers/measurement_repository_test.dart index 36ec5ab7e..f35f0c798 100644 --- a/test/features/measurements/providers/measurement_repository_test.dart +++ b/test/features/measurements/providers/measurement_repository_test.dart @@ -287,4 +287,40 @@ void main() { expect(emitted, isEmpty); }); }); + + group('reorderCategories', () { + test('persists the list positions as order', () async { + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c1', name: 'Aaa', unit: 'cm')); + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c2', name: 'Bbb', unit: 'cm')); + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c3', name: 'Ccc', unit: 'cm')); + + await repo.reorderCategories(['c3', 'c1', 'c2']); + + final emitted = await repo.watchAll().first; + expect(emitted.map((c) => c.id), ['c3', 'c1', 'c2']); + expect(emitted.map((c) => c.order), [0, 1, 2]); + }); + + test('keeps the in-group order of children', () async { + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'bp', name: 'Blood pressure', unit: 'mmHg'), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'sys', name: 'Systolic', unit: 'mmHg', parentId: 'bp', order: 0), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'dia', name: 'Diastolic', unit: 'mmHg', parentId: 'bp', order: 1), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c1', name: 'Waist', unit: 'cm', order: 1), + ); + + await repo.reorderCategories(['c1', 'bp']); + + final emitted = await repo.watchAll().first; + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.order, 1); + expect(parent.children.map((c) => c.id), ['sys', 'dia']); + }); + }); } diff --git a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart index 8fa32a7e8..743054ede 100644 --- a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart @@ -127,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } diff --git a/test/features/measurements/screens/measurement_category_sort_screen_test.dart b/test/features/measurements/screens/measurement_category_sort_screen_test.dart new file mode 100644 index 000000000..884de8d15 --- /dev/null +++ b/test/features/measurements/screens/measurement_category_sort_screen_test.dart @@ -0,0 +1,89 @@ +/* + * 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:flutter/material.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/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +import '../../../../test_data/measurements.dart'; +import 'measurement_category_sort_screen_test.mocks.dart'; + +@GenerateMocks([MeasurementRepository]) +void main() { + late MockMeasurementRepository mockRepo; + + // Body fat ('1') and Biceps ('2') from the shared test data plus a third + // top-level category and a group child. + final categories = [ + ...getMeasurementCategories(), + MeasurementCategory(id: '2a', name: 'Left', unit: 'cm', parentId: '2'), + MeasurementCategory(id: '3', name: 'Waist', unit: 'cm'), + ]; + + setUp(() { + mockRepo = MockMeasurementRepository(); + when(mockRepo.watchAll()).thenAnswer((_) => Stream.value(categories)); + when(mockRepo.reorderCategories(any)).thenAnswer((_) async {}); + }); + + Widget createSortScreen() { + return ProviderScope( + overrides: [ + measurementRepositoryProvider.overrideWithValue(mockRepo), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MeasurementCategorySortScreen(), + ), + ); + } + + testWidgets('lists only top-level categories', (tester) async { + await tester.pumpWidget(createSortScreen()); + await tester.pumpAndSettle(); + + expect(find.text('Body fat'), findsOneWidget); + expect(find.text('Biceps'), findsOneWidget); + expect(find.text('Waist'), findsOneWidget); + expect(find.text('Left'), findsNothing); + expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); + }); + + testWidgets('dragging an item to the bottom persists the new order', (tester) async { + await tester.pumpWidget(createSortScreen()); + await tester.pumpAndSettle(); + + final firstHandle = find.byIcon(Icons.drag_handle).first; + final itemHeight = tester.getSize(find.byType(ListTile).first).height; + await tester.timedDrag( + firstHandle, + Offset(0, itemHeight * 3), + const Duration(milliseconds: 300), + ); + await tester.pumpAndSettle(); + + verify(mockRepo.reorderCategories(['2', '3', '1'])).called(1); + }); +} diff --git a/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart b/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart new file mode 100644 index 000000000..fa002a9c4 --- /dev/null +++ b/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart @@ -0,0 +1,139 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/features/measurements/screens/measurement_category_sort_screen_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/measurements/models/measurement_category.dart' as _i4; +import 'package:wger/features/measurements/models/measurement_entry.dart' as _i5; +import 'package:wger/features/measurements/providers/measurement_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 [MeasurementRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepository { + MockMeasurementRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Stream> watchAll() => + (super.noSuchMethod( + Invocation.method(#watchAll, []), + returnValue: _i3.Stream>.empty(), + ) + as _i3.Stream>); + + @override + _i3.Stream<_i4.MeasurementCategory?> watchLocalDriftCategoryById( + String? id, + ) => + (super.noSuchMethod( + Invocation.method(#watchLocalDriftCategoryById, [id]), + returnValue: _i3.Stream<_i4.MeasurementCategory?>.empty(), + ) + as _i3.Stream<_i4.MeasurementCategory?>); + + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + + @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(_i5.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#updateLocalDrift, [entry]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDrift(_i5.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addLocalDrift, [entry]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future deleteLocalDriftCategory(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDriftCategory, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future updateLocalDriftCategory( + _i4.MeasurementCategory? category, + ) => + (super.noSuchMethod( + Invocation.method(#updateLocalDriftCategory, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDriftCategory(_i4.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftCategory, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} diff --git a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart index c31c80f05..5ca65e223 100644 --- a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart @@ -136,6 +136,15 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } /// A class which mocks [NutritionRepository]. diff --git a/test/screenshots/screenshots_01_dashboard.mocks.dart b/test/screenshots/screenshots_01_dashboard.mocks.dart index f0af1a51a..ebe4614fd 100644 --- a/test/screenshots/screenshots_01_dashboard.mocks.dart +++ b/test/screenshots/screenshots_01_dashboard.mocks.dart @@ -548,6 +548,15 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + + @override + _i10.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); } /// A class which mocks [UserProfileRepository]. diff --git a/test/screenshots/screenshots_04_measurements.mocks.dart b/test/screenshots/screenshots_04_measurements.mocks.dart index ab8aa659f..7272e698c 100644 --- a/test/screenshots/screenshots_04_measurements.mocks.dart +++ b/test/screenshots/screenshots_04_measurements.mocks.dart @@ -127,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } From 3fc33e1acc2d5140e7a6e1bead1b1b8bc3b693cf Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 11 Jul 2026 00:37:10 +0200 Subject: [PATCH 21/22] Add multi-value measurements to the test data --- .../providers/measurement_notifier_test.dart | 22 ++++++------ .../measurement_repository_test.dart | 24 ++++--------- ...measurement_category_sort_screen_test.dart | 15 ++++---- test_data/measurements.dart | 34 +++++++++++++++++++ 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/test/features/measurements/providers/measurement_notifier_test.dart b/test/features/measurements/providers/measurement_notifier_test.dart index 0075fb560..6e78a5c65 100644 --- a/test/features/measurements/providers/measurement_notifier_test.dart +++ b/test/features/measurements/providers/measurement_notifier_test.dart @@ -20,7 +20,6 @@ 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/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/providers/measurement_notifier.dart'; import 'package:wger/features/measurements/providers/measurement_repository.dart'; @@ -105,11 +104,11 @@ void main() { }); group('setCategoryOrder', () { - // Body fat ('1') and Biceps ('2') from the shared test data plus a third - // top-level category. + // Three top-level categories: Body fat ('1'), Biceps ('2') and the blood + // pressure group parent ('bp'), whose children must stay untouched. final categories = [ ...getMeasurementCategories(), - MeasurementCategory(id: '3', name: 'Waist', unit: 'cm'), + ...getBloodPressureGroup(), ]; setUp(() { @@ -128,29 +127,30 @@ void main() { final notifier = await loadedNotifier(); await notifier.setCategoryOrder(0, 2); - verify(mockRepo.reorderCategories(['2', '3', '1'])).called(1); + verify(mockRepo.reorderCategories(['2', 'bp', '1'])).called(1); }); test('moves an item up', () async { final notifier = await loadedNotifier(); await notifier.setCategoryOrder(2, 0); - verify(mockRepo.reorderCategories(['3', '1', '2'])).called(1); + verify(mockRepo.reorderCategories(['bp', '1', '2'])).called(1); }); test('excludes children of multi-value groups', () async { + // Children interleaved between the top-level categories. when(mockRepo.watchAll()).thenAnswer( (_) => Stream.value([ - categories[0], - MeasurementCategory(id: '2a', name: 'Left', unit: 'cm', parentId: '2'), - categories[1], - categories[2], + testMeasurementCategorySystolic, + ...getMeasurementCategories(), + testMeasurementCategoryDiastolic, + testMeasurementCategoryBloodPressure, ]), ); final notifier = await loadedNotifier(); await notifier.setCategoryOrder(0, 1); - verify(mockRepo.reorderCategories(['2', '1', '3'])).called(1); + verify(mockRepo.reorderCategories(['2', '1', 'bp'])).called(1); }); test('does nothing while the list is still loading', () async { diff --git a/test/features/measurements/providers/measurement_repository_test.dart b/test/features/measurements/providers/measurement_repository_test.dart index f35f0c798..9868aab38 100644 --- a/test/features/measurements/providers/measurement_repository_test.dart +++ b/test/features/measurements/providers/measurement_repository_test.dart @@ -225,15 +225,9 @@ void main() { group('multi-value groups', () { Future seedBloodPressureGroup() async { - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'bp', name: 'Blood pressure', unit: 'mmHg'), - ); - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'sys', name: 'Systolic', unit: 'mmHg', parentId: 'bp', order: 0), - ); - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'dia', name: 'Diastolic', unit: 'mmHg', parentId: 'bp', order: 1), - ); + for (final category in getBloodPressureGroup()) { + await repo.addLocalDriftCategory(category); + } } test('watchAll attaches children to their parent in group order', () async { @@ -302,15 +296,9 @@ void main() { }); test('keeps the in-group order of children', () async { - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'bp', name: 'Blood pressure', unit: 'mmHg'), - ); - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'sys', name: 'Systolic', unit: 'mmHg', parentId: 'bp', order: 0), - ); - await repo.addLocalDriftCategory( - MeasurementCategory(id: 'dia', name: 'Diastolic', unit: 'mmHg', parentId: 'bp', order: 1), - ); + for (final category in getBloodPressureGroup()) { + await repo.addLocalDriftCategory(category); + } await repo.addLocalDriftCategory( MeasurementCategory(id: 'c1', name: 'Waist', unit: 'cm', order: 1), ); diff --git a/test/features/measurements/screens/measurement_category_sort_screen_test.dart b/test/features/measurements/screens/measurement_category_sort_screen_test.dart index 884de8d15..6b46b9e68 100644 --- a/test/features/measurements/screens/measurement_category_sort_screen_test.dart +++ b/test/features/measurements/screens/measurement_category_sort_screen_test.dart @@ -21,7 +21,6 @@ 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/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/providers/measurement_repository.dart'; import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -33,12 +32,11 @@ import 'measurement_category_sort_screen_test.mocks.dart'; void main() { late MockMeasurementRepository mockRepo; - // Body fat ('1') and Biceps ('2') from the shared test data plus a third - // top-level category and a group child. + // Three top-level categories: Body fat ('1'), Biceps ('2') and the blood + // pressure group parent ('bp'); its children must not be listed. final categories = [ ...getMeasurementCategories(), - MeasurementCategory(id: '2a', name: 'Left', unit: 'cm', parentId: '2'), - MeasurementCategory(id: '3', name: 'Waist', unit: 'cm'), + ...getBloodPressureGroup(), ]; setUp(() { @@ -66,8 +64,9 @@ void main() { expect(find.text('Body fat'), findsOneWidget); expect(find.text('Biceps'), findsOneWidget); - expect(find.text('Waist'), findsOneWidget); - expect(find.text('Left'), findsNothing); + expect(find.text('Blood pressure'), findsOneWidget); + expect(find.text('Systolic'), findsNothing); + expect(find.text('Diastolic'), findsNothing); expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); }); @@ -84,6 +83,6 @@ void main() { ); await tester.pumpAndSettle(); - verify(mockRepo.reorderCategories(['2', '3', '1'])).called(1); + verify(mockRepo.reorderCategories(['2', 'bp', '1'])).called(1); }); } diff --git a/test_data/measurements.dart b/test_data/measurements.dart index 3ada4ceea..73c237751 100644 --- a/test_data/measurements.dart +++ b/test_data/measurements.dart @@ -78,6 +78,40 @@ final testMeasurementEntry8 = MeasurementEntry( notes: '', ); +/// Multi-value group: blood pressure with systolic/diastolic components. +final testMeasurementCategoryBloodPressure = MeasurementCategory( + id: 'bp', + name: 'Blood pressure', + unit: 'mmHg', + metricType: MetricType.bloodPressure, +); + +final testMeasurementCategorySystolic = MeasurementCategory( + id: 'sys', + name: 'Systolic', + unit: 'mmHg', + parentId: 'bp', + order: 0, +); + +final testMeasurementCategoryDiastolic = MeasurementCategory( + id: 'dia', + name: 'Diastolic', + unit: 'mmHg', + parentId: 'bp', + order: 1, +); + +/// The blood pressure group as a flat list (parent first), as stored in the +/// database. Not included in [getMeasurementCategories]. +List getBloodPressureGroup() { + return [ + testMeasurementCategoryBloodPressure, + testMeasurementCategorySystolic, + testMeasurementCategoryDiastolic, + ]; +} + List getMeasurementCategories() { return [ MeasurementCategory( From 55cdd64fb31f4ba95822399da0983ade5bec716e Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 11 Jul 2026 00:46:20 +0200 Subject: [PATCH 22/22] Fix trigger for CI --- .github/workflows/analyze.yml | 5 ++++- .github/workflows/ci.yml | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 4e7a67558..4d8b8b0f1 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -1,6 +1,10 @@ name: Analyze on: + # Direct pushes are only validated on master (post-merge); everything else + # is covered by the pull_request trigger, avoiding duplicate runs for + # same-repo PR branches. push: + branches: [ master, ] paths: - '**.dart' - 'pubspec.yaml' @@ -9,7 +13,6 @@ on: - 'ios/Runner/Info.plist' - 'android/app/src/main/res/xml/locales_config.xml' pull_request: - branches: [ master, ] paths: - '**/*.dart' - 'pubspec.yaml' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89f67420b..5cb55563c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,14 @@ -name: Continous Integration +name: Continuous Integration on: + # Direct pushes are only validated on master (post-merge); everything else + # is covered by the pull_request trigger, avoiding duplicate runs for + # same-repo PR branches. push: + branches: [ master, ] paths: - '**.dart' - 'pubspec.yaml' pull_request: - branches: [ master, ] paths: - '**/*.dart' - 'pubspec.yaml'