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(