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..b23873870 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,11 +9,20 @@ + + + + + + + + + + + + + + + + + + + + + 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/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/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/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/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/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..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'; @@ -58,7 +59,9 @@ class BodyWeightProvider with ChangeNotifier { WeightEntry? findByDate(DateTime date) { try { - return _entries.firstWhere((plan) => plan.date == date); + return _entries.firstWhere( + (entry) => entry.date.isSameDayAs(date), + ); } on StateError { return null; } diff --git a/lib/providers/health_sync.dart b/lib/providers/health_sync.dart new file mode 100644 index 000000000..1198ff54c --- /dev/null +++ b/lib/providers/health_sync.dart @@ -0,0 +1,255 @@ +/* + * 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, + ); + } +} + +const double kgToLb = 2.20462; + +@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. + /// 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 _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..7dc2c9787 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,19 @@ class _HomeTabsScreenState extends ConsumerState } authProvider.dataInit = true; + + // 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, isMetric: isMetric) + .then((syncCount) { + if (syncCount > 0) { + weightProviderForSync.fetchAndSetEntries(); + } + }); } @override diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 014b281d9..d4208bdd1 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(i18n.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..da738923f --- /dev/null +++ b/lib/widgets/core/settings/health_sync.dart @@ -0,0 +1,90 @@ +/* + * 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: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'; + +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 != true) { + return const SizedBox.shrink(); + } + + final syncState = ref.watch(healthSyncProvider); + + final i18n = AppLocalizations.of(context); + + return SwitchListTile( + title: Text(i18n.healthSync), + subtitle: Text(i18n.healthSyncDescription), + value: syncState.isEnabled, + onChanged: syncState.isSyncing + ? null + : (enabled) async { + final notifier = ref.read(healthSyncProvider.notifier); + if (enabled) { + 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) { + // 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(i18n.healthSyncSuccess(count))), + ); + } + } + } 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/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/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..d5421e4ec 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 @@ -63,7 +64,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 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/providers/health_sync_test.dart b/test/providers/health_sync_test.dart new file mode 100644 index 000000000..1062a5688 --- /dev/null +++ b/test/providers/health_sync_test.dart @@ -0,0 +1,80 @@ +/* + * 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)); + }); + + }); + + 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); + }); + }); +} 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, + ); +} 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(