From a92897b2f9b8e97b69b841fa05925e29e73bc277 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 14:12:49 -0500 Subject: [PATCH 1/8] 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 2/8] 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 7760efc9e3893c9ee0a8871f68a3f65ce996f5a2 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 15:56:37 -0500 Subject: [PATCH 3/8] fix: replace EOL sqlite3_flutter_libs with drift_flutter sqlite3_flutter_libs 0.6.0+eol no longer bundles the native SQLite library correctly on newer Android toolchains, causing a DriftRemoteException on startup (dlopen failed: libsqlite3.so not found). Replace it with drift_flutter which is the current recommended package for providing native SQLite to drift databases. Also suppress the StackFrame assertion error from the stack_trace package that was masking the real error. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/database/exercises/exercise_database.dart | 14 +++----------- lib/database/ingredients/ingredients_database.dart | 14 +++----------- lib/main.dart | 8 ++++++++ pubspec.yaml | 2 +- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/lib/database/exercises/exercise_database.dart b/lib/database/exercises/exercise_database.dart index e7c36a6e7..826935ff4 100644 --- a/lib/database/exercises/exercise_database.dart +++ b/lib/database/exercises/exercise_database.dart @@ -1,10 +1,6 @@ -import 'dart:io'; - import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; +import 'package:drift_flutter/drift_flutter.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:wger/database/exercises/type_converters.dart'; import 'package:wger/models/exercises/category.dart'; import 'package:wger/models/exercises/equipment.dart'; @@ -110,10 +106,6 @@ class ExerciseDatabase extends _$ExerciseDatabase { } } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - final dbFolder = await getApplicationCacheDirectory(); - final file = File(p.join(dbFolder.path, 'exercises.sqlite')); - return NativeDatabase.createInBackground(file); - }); +QueryExecutor _openConnection() { + return driftDatabase(name: 'exercises'); } diff --git a/lib/database/ingredients/ingredients_database.dart b/lib/database/ingredients/ingredients_database.dart index 28f839ed1..0f325e62e 100644 --- a/lib/database/ingredients/ingredients_database.dart +++ b/lib/database/ingredients/ingredients_database.dart @@ -1,10 +1,6 @@ -import 'dart:io'; - import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; +import 'package:drift_flutter/drift_flutter.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; part 'ingredients_database.g.dart'; @@ -63,10 +59,6 @@ class IngredientDatabase extends _$IngredientDatabase { } } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - final dbFolder = await getApplicationCacheDirectory(); - final file = File(p.join(dbFolder.path, 'ingredients.sqlite')); - return NativeDatabase.createInBackground(file); - }); +QueryExecutor _openConnection() { + return driftDatabase(name: 'ingredients'); } diff --git a/lib/main.dart b/lib/main.dart index a13d7afb1..1cc80bcc9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,6 +119,14 @@ void main() async { // Catch errors that happen outside of the Flutter framework (e.g., in async operations) PlatformDispatcher.instance.onError = (error, stack) { + // Skip the StackFrame assertion error from the stack_trace package. + // This is a known Flutter framework issue where async gap markers in stack + // traces cause an assertion failure in StackFrame.fromStackTraceLine. + if (error is AssertionError && error.toString().contains('asynchronous gap')) { + logger.warning('Suppressed StackFrame assertion error (known Flutter issue)'); + return true; + } + logger.severe('Error caught by PlatformDispatcher.instance.onError: $error'); logger.severe('Stack trace: $stack'); diff --git a/pubspec.yaml b/pubspec.yaml index 036307e83..9a4316347 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,7 @@ dependencies: rive: ^0.13.20 riverpod_annotation: ^4.0.0 shared_preferences: ^2.5.3 - sqlite3_flutter_libs: ^0.6.0+eol + drift_flutter: ^0.2.8 table_calendar: ^3.0.8 url_launcher: ^6.3.2 version: ^3.0.2 From 627b8b9fd834f50df010d0f18e048c2936a8bee4 Mon Sep 17 00:00:00 2001 From: John Weidner Date: Fri, 27 Mar 2026 16:32:53 -0500 Subject: [PATCH 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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);