diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 4e7a67558..4d8b8b0f1 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -1,6 +1,10 @@ name: Analyze on: + # Direct pushes are only validated on master (post-merge); everything else + # is covered by the pull_request trigger, avoiding duplicate runs for + # same-repo PR branches. push: + branches: [ master, ] paths: - '**.dart' - 'pubspec.yaml' @@ -9,7 +13,6 @@ on: - 'ios/Runner/Info.plist' - 'android/app/src/main/res/xml/locales_config.xml' pull_request: - branches: [ master, ] paths: - '**/*.dart' - 'pubspec.yaml' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89f67420b..5cb55563c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,14 @@ -name: Continous Integration +name: Continuous Integration on: + # Direct pushes are only validated on master (post-merge); everything else + # is covered by the pull_request trigger, avoiding duplicate runs for + # same-repo PR branches. push: + branches: [ master, ] paths: - '**.dart' - 'pubspec.yaml' pull_request: - branches: [ master, ] paths: - '**/*.dart' - 'pubspec.yaml' 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 df240b94f..2016766b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,11 +9,21 @@ + + + + + + + + + + + + + + + + + + + + + + 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.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 08bd47e13..006be35e8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -77,7 +77,6 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - BB0286322FD60C00014F981C /* Pods */, ); sourceTree = ""; }; @@ -105,13 +104,6 @@ path = Runner; sourceTree = ""; }; - BB0286322FD60C00014F981C /* Pods */ = { - isa = PBXGroup; - children = ( - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -295,7 +287,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -383,7 +375,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -432,7 +424,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d67830a17..60b66f05c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -86,6 +86,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..c77f75e0e 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,7 @@ aps-environment development + com.apple.developer.healthkit + diff --git a/lib/core/home_tabs_screen.dart b/lib/core/home_tabs_screen.dart index f6b5f46ac..3ba7f6498 100644 --- a/lib/core/home_tabs_screen.dart +++ b/lib/core/home_tabs_screen.dart @@ -22,6 +22,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:wger/core/dashboard.dart'; import 'package:wger/core/material.dart'; import 'package:wger/features/gallery/screens/gallery_screen.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; import 'package:wger/features/nutrition/screens/nutritional_plans_screen.dart'; import 'package:wger/features/routines/screens/routine_list_screen.dart'; import 'package:wger/features/weight/screens/weight_screen.dart'; @@ -41,6 +42,20 @@ class _HomeTabsScreenState extends ConsumerState int _selectedIndex = 0; bool _isWideScreen = false; + @override + void initState() { + super.initState(); + + // Pull any new readings from Apple Health / Health Connect once the app is + // open. The sync is a no-op unless the user enabled it in the settings. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + ref.read(healthSyncProvider.notifier).syncOnAppOpen(); + }); + } + @override void didChangeDependencies() { super.didChangeDependencies(); diff --git a/lib/core/shared_preferences.dart b/lib/core/shared_preferences.dart index de3ed3b16..fe03c3179 100644 --- a/lib/core/shared_preferences.dart +++ b/lib/core/shared_preferences.dart @@ -132,4 +132,31 @@ class PreferenceHelper { orElse: () => fallback.searchMode, ); } + + // --- 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/core/widgets/dashboard/widgets/measurements.dart b/lib/core/widgets/dashboard/widgets/measurements.dart index 75eabbcb6..a178bda56 100644 --- a/lib/core/widgets/dashboard/widgets/measurements.dart +++ b/lib/core/widgets/dashboard/widgets/measurements.dart @@ -45,7 +45,10 @@ class _DashboardMeasurementWidgetState extends ConsumerState>( value: ref.watch(measurementProvider), loggerName: 'DashboardMeasurementWidget', - data: (categoriesList) { + data: (allCategories) { + // Children of multi-value groups are shown inside their parent's card + final categoriesList = allCategories.where((c) => c.parentId == null).toList(); + if (categoriesList.isEmpty) { return NothingFound( AppLocalizations.of(context).moreMeasurementEntries, diff --git a/lib/database/converters/measurement_metric_type_converter.dart b/lib/database/converters/measurement_metric_type_converter.dart new file mode 100644 index 000000000..da716261c --- /dev/null +++ b/lib/database/converters/measurement_metric_type_converter.dart @@ -0,0 +1,31 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020 - 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:drift/drift.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; + +/// Maps a [MetricType] to and from the SQLite string format. +class MeasurementMetricTypeConverter extends TypeConverter { + const MeasurementMetricTypeConverter(); + + @override + MetricType fromSql(String fromDb) => MetricType.fromWire(fromDb); + + @override + String toSql(MetricType value) => value.wireValue; +} diff --git a/lib/database/powersync/database.dart b/lib/database/powersync/database.dart index 46f25cf54..4ae7088a5 100644 --- a/lib/database/powersync/database.dart +++ b/lib/database/powersync/database.dart @@ -25,6 +25,7 @@ import 'package:wger/core/language.dart'; import 'package:wger/core/license.dart'; import 'package:wger/database/converters/date_only_text_converter.dart'; import 'package:wger/database/converters/exercise_image_style_converter.dart'; +import 'package:wger/database/converters/measurement_metric_type_converter.dart'; import 'package:wger/database/converters/time_of_day_converter.dart'; import 'package:wger/database/converters/utc_datetime_converter.dart'; import 'package:wger/database/converters/workout_impression_converter.dart'; diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index e09e93822..c01662f61 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -4881,7 +4881,49 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable requiredDuringInsert: true, ); @override - List get $columns => [id, name, unit]; + late final GeneratedColumnWithTypeConverter metricType = + GeneratedColumn( + 'metric_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter( + $MeasurementCategoryTableTable.$convertermetricType, + ); + static const VerificationMeta _parentIdMeta = const VerificationMeta( + 'parentId', + ); + @override + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES measurements_category (id)', + ), + ); + static const VerificationMeta _orderMeta = const VerificationMeta('order'); + @override + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + List get $columns => [ + id, + name, + unit, + metricType, + parentId, + order, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -4913,6 +4955,18 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable } else if (isInserting) { context.missing(_unitMeta); } + if (data.containsKey('parent_id')) { + context.handle( + _parentIdMeta, + parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta), + ); + } + if (data.containsKey('order')) { + context.handle( + _orderMeta, + order.isAcceptableOrUnknown(data['order']!, _orderMeta), + ); + } return context; } @@ -4934,6 +4988,20 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable DriftSqlType.string, data['${effectivePrefix}unit'], )!, + metricType: $MeasurementCategoryTableTable.$convertermetricType.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metric_type'], + )!, + ), + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, ); } @@ -4941,36 +5009,55 @@ class $MeasurementCategoryTableTable extends MeasurementCategoryTable $MeasurementCategoryTableTable createAlias(String alias) { return $MeasurementCategoryTableTable(attachedDatabase, alias); } + + static TypeConverter $convertermetricType = + const MeasurementMetricTypeConverter(); } class MeasurementCategoryTableCompanion extends UpdateCompanion { final Value id; final Value name; final Value unit; + final Value metricType; + final Value parentId; + final Value order; final Value rowid; const MeasurementCategoryTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), this.unit = const Value.absent(), + this.metricType = const Value.absent(), + this.parentId = const Value.absent(), + this.order = const Value.absent(), this.rowid = const Value.absent(), }); MeasurementCategoryTableCompanion.insert({ this.id = const Value.absent(), required String name, required String unit, + required MetricType metricType, + this.parentId = const Value.absent(), + this.order = const Value.absent(), this.rowid = const Value.absent(), }) : name = Value(name), - unit = Value(unit); + unit = Value(unit), + metricType = Value(metricType); static Insertable custom({ Expression? id, Expression? name, Expression? unit, + Expression? metricType, + Expression? parentId, + Expression? order, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, if (unit != null) 'unit': unit, + if (metricType != null) 'metric_type': metricType, + if (parentId != null) 'parent_id': parentId, + if (order != null) 'order': order, if (rowid != null) 'rowid': rowid, }); } @@ -4979,12 +5066,18 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion? id, Value? name, Value? unit, + Value? metricType, + Value? parentId, + Value? order, Value? rowid, }) { return MeasurementCategoryTableCompanion( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, + metricType: metricType ?? this.metricType, + parentId: parentId ?? this.parentId, + order: order ?? this.order, rowid: rowid ?? this.rowid, ); } @@ -5001,6 +5094,19 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion(unit.value); } + if (metricType.present) { + map['metric_type'] = Variable( + $MeasurementCategoryTableTable.$convertermetricType.toSql( + metricType.value, + ), + ); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -5013,6 +5119,9 @@ class MeasurementCategoryTableCompanion extends UpdateCompanion source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('manual'), + ); + static const VerificationMeta _externalIdMeta = const VerificationMeta( + 'externalId', + ); @override - List get $columns => [id, categoryId, date, value, notes]; + late final GeneratedColumn externalId = GeneratedColumn( + 'external_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + categoryId, + date, + value, + notes, + source, + externalId, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -5116,6 +5254,18 @@ class $MeasurementEntryTableTable extends MeasurementEntryTable } else if (isInserting) { context.missing(_notesMeta); } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } + if (data.containsKey('external_id')) { + context.handle( + _externalIdMeta, + externalId.isAcceptableOrUnknown(data['external_id']!, _externalIdMeta), + ); + } return context; } @@ -5147,6 +5297,14 @@ class $MeasurementEntryTableTable extends MeasurementEntryTable DriftSqlType.string, data['${effectivePrefix}notes'], )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + externalId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}external_id'], + ), ); } @@ -5164,6 +5322,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { final Value date; final Value value; final Value notes; + final Value source; + final Value externalId; final Value rowid; const MeasurementEntryTableCompanion({ this.id = const Value.absent(), @@ -5171,6 +5331,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { this.date = const Value.absent(), this.value = const Value.absent(), this.notes = const Value.absent(), + this.source = const Value.absent(), + this.externalId = const Value.absent(), this.rowid = const Value.absent(), }); MeasurementEntryTableCompanion.insert({ @@ -5179,6 +5341,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { required DateTime date, required double value, required String notes, + this.source = const Value.absent(), + this.externalId = const Value.absent(), this.rowid = const Value.absent(), }) : categoryId = Value(categoryId), date = Value(date), @@ -5190,6 +5354,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { Expression? date, Expression? value, Expression? notes, + Expression? source, + Expression? externalId, Expression? rowid, }) { return RawValuesInsertable({ @@ -5198,6 +5364,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { if (date != null) 'date': date, if (value != null) 'value': value, if (notes != null) 'notes': notes, + if (source != null) 'source': source, + if (externalId != null) 'external_id': externalId, if (rowid != null) 'rowid': rowid, }); } @@ -5208,6 +5376,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { Value? date, Value? value, Value? notes, + Value? source, + Value? externalId, Value? rowid, }) { return MeasurementEntryTableCompanion( @@ -5216,6 +5386,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { date: date ?? this.date, value: value ?? this.value, notes: notes ?? this.notes, + source: source ?? this.source, + externalId: externalId ?? this.externalId, rowid: rowid ?? this.rowid, ); } @@ -5240,6 +5412,12 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { if (notes.present) { map['notes'] = Variable(notes.value); } + if (source.present) { + map['source'] = Variable(source.value); + } + if (externalId.present) { + map['external_id'] = Variable(externalId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -5254,6 +5432,8 @@ class MeasurementEntryTableCompanion extends UpdateCompanion { ..write('date: $date, ') ..write('value: $value, ') ..write('notes: $notes, ') + ..write('source: $source, ') + ..write('externalId: $externalId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -16368,6 +16548,9 @@ typedef $$MeasurementCategoryTableTableCreateCompanionBuilder = Value id, required String name, required String unit, + required MetricType metricType, + Value parentId, + Value order, Value rowid, }); typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = @@ -16375,6 +16558,9 @@ typedef $$MeasurementCategoryTableTableUpdateCompanionBuilder = Value id, Value name, Value unit, + Value metricType, + Value parentId, + Value order, Value rowid, }); @@ -16391,6 +16577,26 @@ final class $$MeasurementCategoryTableTableReferences super.$_typedResult, ); + static $MeasurementCategoryTableTable _parentIdTable( + _$DriftPowersyncDatabase db, + ) => db.measurementCategoryTable.createAlias( + 'measurements_category__parent_id__measurements_category__id', + ); + + $$MeasurementCategoryTableTableProcessedTableManager? get parentId { + final $_column = $_itemColumn('parent_id'); + if ($_column == null) return null; + final manager = $$MeasurementCategoryTableTableTableManager( + $_db, + $_db.measurementCategoryTable, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_parentIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + static MultiTypedResultKey<$MeasurementEntryTableTable, List> _measurementEntryTableRefsTable(_$DriftPowersyncDatabase db) => MultiTypedResultKey.fromTable( db.measurementEntryTable, @@ -16436,6 +16642,39 @@ class $$MeasurementCategoryTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnWithTypeConverterFilters get metricType => + $composableBuilder( + column: $table.metricType, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get order => $composableBuilder( + column: $table.order, + builder: (column) => ColumnFilters(column), + ); + + $$MeasurementCategoryTableTableFilterComposer get parentId { + final $$MeasurementCategoryTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableFilterComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableFilterComposer f) f, ) { @@ -16484,6 +16723,38 @@ class $$MeasurementCategoryTableTableOrderingComposer column: $table.unit, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get metricType => $composableBuilder( + column: $table.metricType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get order => $composableBuilder( + column: $table.order, + builder: (column) => ColumnOrderings(column), + ); + + $$MeasurementCategoryTableTableOrderingComposer get parentId { + final $$MeasurementCategoryTableTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableOrderingComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } class $$MeasurementCategoryTableTableAnnotationComposer @@ -16504,6 +16775,36 @@ class $$MeasurementCategoryTableTableAnnotationComposer GeneratedColumn get unit => $composableBuilder(column: $table.unit, builder: (column) => column); + GeneratedColumnWithTypeConverter get metricType => $composableBuilder( + column: $table.metricType, + builder: (column) => column, + ); + + GeneratedColumn get order => + $composableBuilder(column: $table.order, builder: (column) => column); + + $$MeasurementCategoryTableTableAnnotationComposer get parentId { + final $$MeasurementCategoryTableTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.measurementCategoryTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MeasurementCategoryTableTableAnnotationComposer( + $db: $db, + $table: $db.measurementCategoryTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + Expression measurementEntryTableRefs( Expression Function($$MeasurementEntryTableTableAnnotationComposer a) f, ) { @@ -16542,7 +16843,10 @@ class $$MeasurementCategoryTableTableTableManager $$MeasurementCategoryTableTableUpdateCompanionBuilder, (MeasurementCategory, $$MeasurementCategoryTableTableReferences), MeasurementCategory, - PrefetchHooks Function({bool measurementEntryTableRefs}) + PrefetchHooks Function({ + bool parentId, + bool measurementEntryTableRefs, + }) > { $$MeasurementCategoryTableTableTableManager( _$DriftPowersyncDatabase db, @@ -16568,11 +16872,17 @@ class $$MeasurementCategoryTableTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value unit = const Value.absent(), + Value metricType = const Value.absent(), + Value parentId = const Value.absent(), + Value order = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion( id: id, name: name, unit: unit, + metricType: metricType, + parentId: parentId, + order: order, rowid: rowid, ), createCompanionCallback: @@ -16580,11 +16890,17 @@ class $$MeasurementCategoryTableTableTableManager Value id = const Value.absent(), required String name, required String unit, + required MetricType metricType, + Value parentId = const Value.absent(), + Value order = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementCategoryTableCompanion.insert( id: id, name: name, unit: unit, + metricType: metricType, + parentId: parentId, + order: order, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -16595,13 +16911,44 @@ class $$MeasurementCategoryTableTableTableManager ), ) .toList(), - prefetchHooksCallback: ({measurementEntryTableRefs = false}) { + prefetchHooksCallback: ({parentId = false, measurementEntryTableRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ if (measurementEntryTableRefs) db.measurementEntryTable, ], - addJoins: null, + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (parentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.parentId, + referencedTable: $$MeasurementCategoryTableTableReferences + ._parentIdTable(db), + referencedColumn: $$MeasurementCategoryTableTableReferences + ._parentIdTable(db) + .id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return [ if (measurementEntryTableRefs) @@ -16619,7 +16966,9 @@ class $$MeasurementCategoryTableTableTableManager p0, ).measurementEntryTableRefs, referencedItemsForCurrentItem: (item, referencedItems) => - referencedItems.where((e) => e.categoryId == item.id), + referencedItems.where( + (e) => e.categoryId == item.id, + ), typedResults: items, ), ]; @@ -16642,7 +16991,7 @@ typedef $$MeasurementCategoryTableTableProcessedTableManager = $$MeasurementCategoryTableTableUpdateCompanionBuilder, (MeasurementCategory, $$MeasurementCategoryTableTableReferences), MeasurementCategory, - PrefetchHooks Function({bool measurementEntryTableRefs}) + PrefetchHooks Function({bool parentId, bool measurementEntryTableRefs}) >; typedef $$MeasurementEntryTableTableCreateCompanionBuilder = MeasurementEntryTableCompanion Function({ @@ -16651,6 +17000,8 @@ typedef $$MeasurementEntryTableTableCreateCompanionBuilder = required DateTime date, required double value, required String notes, + Value source, + Value externalId, Value rowid, }); typedef $$MeasurementEntryTableTableUpdateCompanionBuilder = @@ -16660,6 +17011,8 @@ typedef $$MeasurementEntryTableTableUpdateCompanionBuilder = Value date, Value value, Value notes, + Value source, + Value externalId, Value rowid, }); @@ -16722,6 +17075,16 @@ class $$MeasurementEntryTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => ColumnFilters(column), + ); + $$MeasurementCategoryTableTableFilterComposer get categoryId { final $$MeasurementCategoryTableTableFilterComposer composer = $composerBuilder( composer: this, @@ -16774,6 +17137,16 @@ class $$MeasurementEntryTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => ColumnOrderings(column), + ); + $$MeasurementCategoryTableTableOrderingComposer get categoryId { final $$MeasurementCategoryTableTableOrderingComposer composer = $composerBuilder( composer: this, @@ -16818,6 +17191,14 @@ class $$MeasurementEntryTableTableAnnotationComposer GeneratedColumn get notes => $composableBuilder(column: $table.notes, builder: (column) => column); + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + GeneratedColumn get externalId => $composableBuilder( + column: $table.externalId, + builder: (column) => column, + ); + $$MeasurementCategoryTableTableAnnotationComposer get categoryId { final $$MeasurementCategoryTableTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -16882,6 +17263,8 @@ class $$MeasurementEntryTableTableTableManager Value date = const Value.absent(), Value value = const Value.absent(), Value notes = const Value.absent(), + Value source = const Value.absent(), + Value externalId = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementEntryTableCompanion( id: id, @@ -16889,6 +17272,8 @@ class $$MeasurementEntryTableTableTableManager date: date, value: value, notes: notes, + source: source, + externalId: externalId, rowid: rowid, ), createCompanionCallback: @@ -16898,6 +17283,8 @@ class $$MeasurementEntryTableTableTableManager required DateTime date, required double value, required String notes, + Value source = const Value.absent(), + Value externalId = const Value.absent(), Value rowid = const Value.absent(), }) => MeasurementEntryTableCompanion.insert( id: id, @@ -16905,6 +17292,8 @@ class $$MeasurementEntryTableTableTableManager date: date, value: value, notes: notes, + source: source, + externalId: externalId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/database/powersync/tables/measurements.dart b/lib/database/powersync/tables/measurements.dart index cb9f2d247..cd16325a6 100644 --- a/lib/database/powersync/tables/measurements.dart +++ b/lib/database/powersync/tables/measurements.dart @@ -18,6 +18,7 @@ import 'package:drift/drift.dart'; import 'package:powersync/powersync.dart' as ps; +import 'package:wger/database/converters/measurement_metric_type_converter.dart'; import 'package:wger/database/converters/utc_datetime_converter.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/models/measurement_entry.dart'; @@ -31,6 +32,16 @@ class MeasurementCategoryTable extends Table { TextColumn get name => text()(); TextColumn get unit => text()(); + TextColumn get metricType => + text().named('metric_type').map(const MeasurementMetricTypeConverter())(); + + /// Multi-value groups: parent category id (max. one level of nesting; only + /// leaf categories carry entries). + TextColumn get parentId => + text().named('parent_id').nullable().references(MeasurementCategoryTable, #id)(); + + /// Position in the category list; for children, the position within the group + IntColumn get order => integer().withDefault(const Constant(0))(); } const PowersyncMeasurementCategoryTable = ps.Table( @@ -38,6 +49,12 @@ const PowersyncMeasurementCategoryTable = ps.Table( [ ps.Column.text('name'), ps.Column.text('unit'), + ps.Column.text('metric_type'), + ps.Column.text('parent_id'), + ps.Column.integer('order'), + ], + indexes: [ + ps.Index('parent_idx', [ps.IndexedColumn('parent_id')]), ], ); @@ -53,6 +70,14 @@ class MeasurementEntryTable extends Table { DateTimeColumn get date => dateTime().map(const UtcDateTimeConverter())(); RealColumn get value => real()(); TextColumn get notes => text()(); + + /// Where the reading came from: `manual` or a health platform + /// (`apple_health`, `health_connect`). + TextColumn get source => text().withDefault(const Constant('manual'))(); + + /// Platform record UUID, used to deduplicate re-imports. `null` for manual + /// entries. + TextColumn get externalId => text().named('external_id').nullable()(); } const PowersyncMeasurementEntryTable = ps.Table( @@ -62,8 +87,11 @@ const PowersyncMeasurementEntryTable = ps.Table( ps.Column.text('date'), ps.Column.real('value'), ps.Column.text('notes'), + ps.Column.text('source'), + ps.Column.text('external_id'), ], indexes: [ ps.Index('category_idx', [ps.IndexedColumn('category_id')]), + ps.Index('external_id_idx', [ps.IndexedColumn('external_id')]), ], ); diff --git a/lib/features/account/widgets/settings.dart b/lib/features/account/widgets/settings.dart index 697e3bf4e..d430f6cc0 100644 --- a/lib/features/account/widgets/settings.dart +++ b/lib/features/account/widgets/settings.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/features/account/widgets/settings/data_privacy.dart'; +import 'package:wger/features/account/widgets/settings/health_sync.dart'; import 'package:wger/features/account/widgets/settings/image_cache.dart'; import 'package:wger/features/account/widgets/settings/language.dart'; import 'package:wger/features/account/widgets/settings/theme.dart'; @@ -53,6 +54,10 @@ class SettingsPage extends StatelessWidget { ), ), const SettingsDataPrivacy(), + 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(), const SettingsLanguage(), diff --git a/lib/features/account/widgets/settings/health_sync.dart b/lib/features/account/widgets/settings/health_sync.dart new file mode 100644 index 000000000..91d3528d2 --- /dev/null +++ b/lib/features/account/widgets/settings/health_sync.dart @@ -0,0 +1,82 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 - 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * 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/features/health/providers/health_sync.dart'; +import 'package:wger/l10n/generated/app_localizations.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 count = await notifier.enableSync(); + // Imported entries land in the local Drift DB and surface through + // the measurement stream automatically, so no manual refresh is needed. + if (context.mounted && count > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(i18n.healthSyncSuccess(count))), + ); + } + } else { + await notifier.disableSync(); + } + }, + ); + } +} diff --git a/lib/features/health/models/health_metric.dart b/lib/features/health/models/health_metric.dart new file mode 100644 index 000000000..a1e48102a --- /dev/null +++ b/lib/features/health/models/health_metric.dart @@ -0,0 +1,134 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:health_bridge/health.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; + +/// A body metric that can be imported from Apple Health / Health Connect into a +/// measurement category. +/// +/// [metricType] is stored on the created category; it drives the mapping back +/// to a category on the next import. +class HealthMetric { + const HealthMetric({ + required this.metricType, + required this.dataType, + required this.canonicalName, + required this.unit, + required this.toCategoryValue, + this.enabled = false, + this.disabledReason, + }); + + /// Metric type stored on the category, e.g. [MetricType.bodyFat]. + final MetricType metricType; + + /// Health platform type this metric reads. + final HealthDataType dataType; + + /// Stable, non-localized category name. Used to find-or-create the category, + /// and as the fallback match for a category the user already created by hand + /// (which carries [MetricType.custom], not this metric's type). + final String canonicalName; + + /// Unit the value is stored in on the category. + final String unit; + + /// Converts the platform's numeric value (in its native unit) into [unit]. + final double Function(double raw) toCategoryValue; + + /// Whether V1 imports this metric. Disabled ones are declared for visibility + /// and blocked on further groundwork (see [disabledReason]). + final bool enabled; + + /// Why a declared metric is not imported yet. + final String? disabledReason; +} + +/// Apple Health / Health Connect report body fat as a fraction on iOS (0.15) +/// but as a percentage on Health Connect (15). A real body fat percentage is +/// never below ~1.5, and a fraction is always below 1, so the magnitude tells +/// the two apart without branching on platform. +double _bodyFatToPercent(double raw) => raw <= 1.5 ? raw * 100 : raw; + +/// Height is reported in meters on both platforms (~1.75). Guard against a +/// value that already arrived in centimeters. +double _heightToCm(double raw) => raw < 3 ? raw * 100 : raw; + +double _identity(double raw) => raw; + +/// The V1 metric set (see `plan-measurements-health-v27.md`). +/// +/// Only [HealthMetric.enabled] entries are imported. The rest are declared so +/// the mapping is visible in one place; each is blocked on groundwork noted in +/// its [HealthMetric.disabledReason]. +const List healthMetrics = [ + HealthMetric( + metricType: MetricType.bodyFat, + dataType: HealthDataType.BODY_FAT_PERCENTAGE, + canonicalName: 'Body fat', + unit: '%', + toCategoryValue: _bodyFatToPercent, + enabled: true, + ), + HealthMetric( + metricType: MetricType.height, + dataType: HealthDataType.HEIGHT, + canonicalName: 'Height', + unit: 'cm', + toCategoryValue: _heightToCm, + enabled: true, + ), + HealthMetric( + metricType: MetricType.bodyWeight, + dataType: HealthDataType.WEIGHT, + canonicalName: 'Weight', + unit: 'kg', + toCategoryValue: _identity, + disabledReason: + 'Body weight lives in its own feature until the ' + 'weight/measurements merge (metric_type=body_weight).', + ), + HealthMetric( + metricType: MetricType.bloodPressure, + dataType: HealthDataType.BLOOD_PRESSURE_SYSTOLIC, + canonicalName: 'Blood pressure', + unit: 'mmHg', + toCategoryValue: _identity, + disabledReason: 'Needs measurement grouping to pair systolic/diastolic.', + ), + HealthMetric( + metricType: MetricType.heartRate, + dataType: HealthDataType.HEART_RATE, + canonicalName: 'Heart rate', + unit: 'bpm', + toCategoryValue: _identity, + disabledReason: 'High-frequency; needs a per-day aggregation strategy.', + ), + HealthMetric( + metricType: MetricType.steps, + dataType: HealthDataType.STEPS, + canonicalName: 'Steps', + unit: 'count', + toCategoryValue: _identity, + disabledReason: 'High-volume cumulative type, parked behind a load test.', + ), +]; + +/// The enabled subset that the importer actually pulls. +List get enabledHealthMetrics => healthMetrics.where((m) => m.enabled).toList(); diff --git a/lib/features/health/models/health_reading.dart b/lib/features/health/models/health_reading.dart new file mode 100644 index 000000000..64101adf7 --- /dev/null +++ b/lib/features/health/models/health_reading.dart @@ -0,0 +1,59 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:health_bridge/health.dart'; + +/// A single reading pulled from a health platform, reduced to the fields the +/// importer needs. Keeps the `health` package's [HealthDataPoint] out of the +/// notifier so the sync logic can be tested with plain values. +class HealthReading { + const HealthReading({ + required this.type, + required this.value, + required this.date, + this.externalId, + }); + + /// The platform type this reading belongs to (matched against + /// `HealthMetric.dataType`). + final HealthDataType type; + + /// Numeric value in the platform's native unit. + final double value; + + /// Start of the reading. + final DateTime date; + + /// Platform record UUID for deduplication; `null` when the platform gives none. + final String? externalId; + + /// Builds a reading from a platform data point, or `null` for a non-numeric + /// point (which the importer cannot store). + static HealthReading? fromDataPoint(HealthDataPoint point) { + final value = point.value; + if (value is! NumericHealthValue) { + return null; + } + return HealthReading( + type: point.type, + value: value.numericValue.toDouble(), + date: point.dateFrom, + externalId: point.uuid.isEmpty ? null : point.uuid, + ); + } +} diff --git a/lib/features/health/providers/health_repository.dart b/lib/features/health/providers/health_repository.dart new file mode 100644 index 000000000..c70d8032b --- /dev/null +++ b/lib/features/health/providers/health_repository.dart @@ -0,0 +1,85 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:health_bridge/health.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/features/health/models/health_reading.dart'; + +final healthRepositoryProvider = Provider((ref) { + return HealthRepository(); +}); + +/// Wraps the `health` plugin so the rest of the app talks to a small, mockable +/// surface instead of Apple Health / Health Connect directly. +class HealthRepository { + HealthRepository([Health? health]) : _health = health ?? Health(); + + final Health _health; + final _logger = Logger('HealthRepository'); + + /// The measurement `source` value for readings from this platform: `apple` + /// for Apple Health, `google` for Health Connect. + String get sourceName => Platform.isIOS ? 'apple' : 'google'; + + /// Whether a health platform is usable on this device. + Future isAvailable() async { + if (Platform.isAndroid) { + await _health.configure(); + final status = await _health.getHealthConnectSdkStatus(); + return status == HealthConnectSdkStatus.sdkAvailable; + } + return Platform.isIOS; + } + + /// Ensures READ access to [types] (requesting it if needed) and, on Android, + /// access to historical data. Returns whether access is granted. + Future ensureAuthorized(List types) async { + await _health.configure(); + final access = List.filled(types.length, HealthDataAccess.READ); + + final hasPerms = await _health.hasPermissions(types, permissions: access); + if (hasPerms != true) { + final granted = await _health.requestAuthorization(types, permissions: access); + if (!granted) { + _logger.warning('Health permissions not granted'); + return false; + } + } + + if (Platform.isAndroid) { + await _health.requestHealthDataHistoryAuthorization(); + } + return true; + } + + /// Reads all [types] between [start] and [end], platform-deduplicated and + /// reduced to numeric [HealthReading]s. + Future> read({ + required List types, + required DateTime start, + required DateTime end, + }) async { + final points = _health.removeDuplicates( + await _health.getHealthDataFromTypes(types: types, startTime: start, endTime: end), + ); + return points.map(HealthReading.fromDataPoint).whereType().toList(); + } +} diff --git a/lib/features/health/providers/health_sync.dart b/lib/features/health/providers/health_sync.dart new file mode 100644 index 000000000..b45c8edd0 --- /dev/null +++ b/lib/features/health/providers/health_sync.dart @@ -0,0 +1,215 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 - 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:collection/collection.dart'; +import 'package:health_bridge/health.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync/powersync.dart' as ps; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wger/core/shared_preferences.dart'; +import 'package:wger/features/health/models/health_metric.dart'; +import 'package:wger/features/health/providers/health_repository.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/measurement_entry.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; + +part 'health_sync.g.dart'; + +class HealthSyncState { + final bool isEnabled; + final bool isSyncing; + final int lastSyncCount; + + const HealthSyncState({ + this.isEnabled = false, + this.isSyncing = false, + this.lastSyncCount = 0, + }); + + HealthSyncState copyWith({bool? isEnabled, bool? isSyncing, int? lastSyncCount}) { + return HealthSyncState( + isEnabled: isEnabled ?? this.isEnabled, + isSyncing: isSyncing ?? this.isSyncing, + lastSyncCount: lastSyncCount ?? this.lastSyncCount, + ); + } +} + +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). +@Riverpod(keepAlive: true) +class HealthSyncNotifier extends _$HealthSyncNotifier { + final _logger = Logger('HealthSyncNotifier'); + late final HealthRepository _health; + late final MeasurementRepository _measurements; + + List get _types => enabledHealthMetrics.map((m) => m.dataType).toList(); + + @override + HealthSyncState build() { + _health = ref.read(healthRepositoryProvider); + _measurements = ref.read(measurementRepositoryProvider); + _loadPersistedState(); + return const HealthSyncState(); + } + + Future _loadPersistedState() async { + if (await PreferenceHelper.instance.getHealthSyncEnabled()) { + state = state.copyWith(isEnabled: true); + } + } + + /// Whether a health platform is available on this device. + Future isAvailable() => _health.isAvailable(); + + /// Requests permissions, persists the preference, and runs an initial import. + Future enableSync() async { + _logger.info('Enabling health sync'); + if (!await _health.ensureAuthorized(_types)) { + return 0; + } + await PreferenceHelper.instance.setHealthSyncEnabled(true); + state = state.copyWith(isEnabled: true); + return syncOnAppOpen(); + } + + /// Clears the preference and disables importing. + Future disableSync() async { + _logger.info('Disabling health sync'); + await PreferenceHelper.instance.clearHealthSyncPreferences(); + state = const HealthSyncState(); + } + + /// Reads the enabled metrics from the health platform and writes any new + /// readings to the matching measurement categories. Returns the number of + /// imported entries. A no-op unless the user enabled sync. + Future syncOnAppOpen() async { + final prefs = PreferenceHelper.instance; + if (!await prefs.getHealthSyncEnabled()) { + return 0; + } + if (state.isSyncing) { + return 0; + } + state = state.copyWith(isEnabled: true, isSyncing: true); + + try { + if (!await _health.ensureAuthorized(_types)) { + _logger.warning('Health permissions not granted during sync'); + state = state.copyWith(isSyncing: false); + return 0; + } + + final lastSyncStr = await prefs.getLastHealthSyncTimestamp(); + final startTime = lastSyncStr != null ? DateTime.parse(lastSyncStr) : DateTime(2000); + final endTime = DateTime.now(); + _logger.info('Syncing health data from $startTime to $endTime'); + + final readings = await _health.read(types: _types, start: startTime, end: endTime); + if (readings.isEmpty) { + _logger.info('No new health data'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + + final categories = await _measurements.getAllOnce(); + final source = _health.sourceName; + var synced = 0; + DateTime? latest; + + for (final metric in enabledHealthMetrics) { + final metricReadings = readings.where((r) => r.type == metric.dataType); + if (metricReadings.isEmpty) { + continue; + } + + final category = await _findOrCreateCategory(metric, categories); + final seen = { + for (final e in category.entries) + if (e.externalId != null) e.externalId!, + }; + + for (final reading in metricReadings) { + final uuid = reading.externalId; + if (uuid != null && seen.contains(uuid)) { + continue; + } + + await _measurements.addLocalDrift( + MeasurementEntry( + categoryId: category.id!, + date: reading.date, + value: metric.toCategoryValue(reading.value), + notes: '', + source: source, + externalId: uuid, + ), + ); + + if (uuid != null) { + seen.add(uuid); + } + synced++; + if (latest == null || reading.date.isAfter(latest)) { + latest = reading.date; + } + } + } + + if (latest != null) { + await prefs.setLastHealthSyncTimestamp(latest.toIso8601String()); + } + _logger.info('Imported $synced health measurements'); + state = state.copyWith(isSyncing: false, lastSyncCount: synced); + return synced; + } catch (e) { + _logger.warning('Health sync failed: $e'); + state = state.copyWith(isSyncing: false, lastSyncCount: 0); + return 0; + } + } + + /// Finds the category for [metric] (by `metric_type`, falling back to the + /// canonical name to reuse a matching category the user created by hand) or + /// creates it. The created category is appended to [categories] so a later + /// metric in the same run reuses it. + Future _findOrCreateCategory( + HealthMetric metric, + List categories, + ) async { + final existing = categories.firstWhereOrNull( + (c) => c.metricType == metric.metricType || c.name == metric.canonicalName, + ); + if (existing != null) { + return existing; + } + + final category = MeasurementCategory( + id: ps.uuid.v7(), + name: metric.canonicalName, + unit: metric.unit, + metricType: metric.metricType, + ); + await _measurements.addLocalDriftCategory(category); + categories.add(category); + return category; + } +} diff --git a/lib/features/health/providers/health_sync.g.dart b/lib/features/health/providers/health_sync.g.dart new file mode 100644 index 000000000..686cde9b1 --- /dev/null +++ b/lib/features/health/providers/health_sync.g.dart @@ -0,0 +1,83 @@ +// 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 +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). + +@ProviderFor(HealthSyncNotifier) +final healthSyncProvider = HealthSyncNotifierProvider._(); + +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). +final class HealthSyncNotifierProvider + extends $NotifierProvider { + /// Imports body metrics from Apple Health / Health Connect into measurement + /// categories. Read-only: reads the platform (via [HealthRepository]), writes to + /// the local Drift DB, and lets PowerSync push the rows up. Re-imports are + /// deduplicated via each measurement's [MeasurementEntry.externalId] (the + /// platform record UUID). + HealthSyncNotifierProvider._() + : super( + from: null, + 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'9c7a678e7740bfa32db7638bd227d64da3bfef09'; + +/// Imports body metrics from Apple Health / Health Connect into measurement +/// categories. Read-only: reads the platform (via [HealthRepository]), writes to +/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are +/// deduplicated via each measurement's [MeasurementEntry.externalId] (the +/// platform record UUID). + +abstract class _$HealthSyncNotifier extends $Notifier { + HealthSyncState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + HealthSyncState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/lib/features/measurements/models/measurement_category.dart b/lib/features/measurements/models/measurement_category.dart index 3dd72751e..24eca5553 100644 --- a/lib/features/measurements/models/measurement_category.dart +++ b/lib/features/measurements/models/measurement_category.dart @@ -17,13 +17,66 @@ */ import 'package:drift/drift.dart' hide JsonKey; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/database/powersync/database.dart'; import 'package:wger/features/measurements/models/measurement_entry.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; part 'measurement_category.freezed.dart'; +/// The wire values mirror the Django API field names (e.g., 'body_weight', 'heart_rate'), +enum MetricType { + custom('custom'), + bodyWeight('body_weight'), + bodyFat('body_fat'), + height('height'), + bloodPressure('blood_pressure'), + heartRate('heart_rate'), + steps('steps'), + distance('distance'), + energy('energy'), + sleep('sleep'); + + final String wireValue; + const MetricType(this.wireValue); + + /// Looks up an enum case by its Django wire value. + /// + /// Defaults to [MetricType.custom] if the value is not recognized. + static MetricType fromWire(String value) => MetricType.values.firstWhere( + (e) => e.wireValue == value, + orElse: () => MetricType.custom, + ); + + /// `true` for metric types that should be aggregated per-day and shown as + /// a bar/histogram instead of a raw-sample line chart. + bool get isSummedPerDay => switch (this) { + MetricType.steps || MetricType.distance || MetricType.energy || MetricType.sleep => true, + _ => false, + }; +} + +extension MeasurementMetricTypeL10n on MetricType { + /// Localized human-readable label (e.g., "Body Weight", "Heart Rate"). + String localized(BuildContext context) { + final l10n = AppLocalizations.of(context); + return switch (this) { + MetricType.custom => l10n.metricCustom, + MetricType.bodyWeight => l10n.metricBodyWeight, + MetricType.bodyFat => l10n.metricBodyFat, + MetricType.height => l10n.metricHeight, + MetricType.bloodPressure => l10n.metricBloodPressure, + MetricType.heartRate => l10n.metricHeartRate, + MetricType.steps => l10n.metricSteps, + MetricType.distance => l10n.metricDistance, + MetricType.energy => l10n.metricEnergy, + MetricType.sleep => l10n.metricSleep, + }; + } +} + @freezed class MeasurementCategory with _$MeasurementCategory { /// Inclusive upper bound for [name] @@ -45,13 +98,41 @@ class MeasurementCategory with _$MeasurementCategory { @override final List entries; + /// Drives the health-platform mapping (and, later, default unit/aggregation/ + /// chart). [MetricType.custom] for plain user-created categories. + @override + final MetricType metricType; + + /// Multi-value groups (e.g. blood pressure): id of the parent category, one + /// child per component. Max. one level of nesting; only leaf categories + /// (no children) carry entries. + @override + final String? parentId; + + /// Position in the category list; for children, the position within the group + @override + final int order; + + /// Child categories (components) of this group. Populated by the repository + /// for display, never persisted directly. + @override + final List children; + MeasurementCategory({ this.id, this.name = '', this.unit = '', this.entries = const [], + this.metricType = MetricType.custom, + this.parentId, + this.order = 0, + this.children = const [], }); + /// `true` for group parents (blood pressure etc.), which hold no entries of + /// their own + bool get isGroup => children.isNotEmpty; + MeasurementEntry findEntryById(String id) { return entries.firstWhere( (entry) => entry.id == id, @@ -65,6 +146,9 @@ class MeasurementCategory with _$MeasurementCategory { id: id != null ? Value(id!) : const Value.absent(), name: Value(name), unit: Value(unit), + metricType: Value(metricType), + parentId: Value(parentId), + order: Value(order), ); } } diff --git a/lib/features/measurements/models/measurement_category.freezed.dart b/lib/features/measurements/models/measurement_category.freezed.dart index 4d6754060..d5137c0bb 100644 --- a/lib/features/measurements/models/measurement_category.freezed.dart +++ b/lib/features/measurements/models/measurement_category.freezed.dart @@ -15,7 +15,15 @@ T _$identity(T value) => value; mixin _$MeasurementCategory { /// Client-generated UUID, is `null` only before the first persist - String? get id; String get name; String get unit; List get entries; + String? get id; String get name; String get unit; List get entries;/// Drives the health-platform mapping (and, later, default unit/aggregation/ +/// chart). [MetricType.custom] for plain user-created categories. + MetricType get metricType;/// Multi-value groups (e.g. blood pressure): id of the parent category, one +/// child per component. Max. one level of nesting; only leaf categories +/// (no children) carry entries. + String? get parentId;/// Position in the category list; for children, the position within the group + int get order;/// Child categories (components) of this group. Populated by the repository +/// for display, never persisted directly. + List get children; /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +34,16 @@ $MeasurementCategoryCopyWith get copyWith => _$MeasurementC @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.unit, unit) || other.unit == unit)&&const DeepCollectionEquality().equals(other.entries, entries)&&(identical(other.metricType, metricType) || other.metricType == metricType)&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.order, order) || other.order == order)&&const DeepCollectionEquality().equals(other.children, children)); } @override -int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries)); +int get hashCode => Object.hash(runtimeType,id,name,unit,const DeepCollectionEquality().hash(entries),metricType,parentId,order,const DeepCollectionEquality().hash(children)); @override String toString() { - return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries)'; + return 'MeasurementCategory(id: $id, name: $name, unit: $unit, entries: $entries, metricType: $metricType, parentId: $parentId, order: $order, children: $children)'; } @@ -46,7 +54,7 @@ abstract mixin class $MeasurementCategoryCopyWith<$Res> { factory $MeasurementCategoryCopyWith(MeasurementCategory value, $Res Function(MeasurementCategory) _then) = _$MeasurementCategoryCopyWithImpl; @useResult $Res call({ - String? id, String name, String unit, List entries + String? id, String name, String unit, List entries, MetricType metricType, String? parentId, int order, List children }); @@ -63,13 +71,17 @@ class _$MeasurementCategoryCopyWithImpl<$Res> /// Create a copy of MeasurementCategory /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? unit = null,Object? entries = null,Object? metricType = null,Object? parentId = freezed,Object? order = null,Object? children = null,}) { return _then(MeasurementCategory( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable as String,entries: null == entries ? _self.entries : entries // ignore: cast_nullable_to_non_nullable -as List, +as List,metricType: null == metricType ? _self.metricType : metricType // ignore: cast_nullable_to_non_nullable +as MetricType,parentId: freezed == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/features/measurements/models/measurement_entry.dart b/lib/features/measurements/models/measurement_entry.dart index 2d5e8f636..0f0b154cf 100644 --- a/lib/features/measurements/models/measurement_entry.dart +++ b/lib/features/measurements/models/measurement_entry.dart @@ -43,12 +43,24 @@ class MeasurementEntry with _$MeasurementEntry { @override final String notes; + /// Origin of the reading; one of the server's `source` values + /// (`user`, `google`, `apple`). + @override + final String source; + + /// Platform record UUID, used to deduplicate re-imports. `null` for manual + /// entries. + @override + final String? externalId; + MeasurementEntry({ this.id, required this.categoryId, required this.date, required this.value, required this.notes, + this.source = 'user', + this.externalId, }); // Boilerplate @@ -59,6 +71,8 @@ class MeasurementEntry with _$MeasurementEntry { date: Value(date), value: Value(value.toDouble()), notes: Value(notes), + source: Value(source), + externalId: Value(externalId), ); } } diff --git a/lib/features/measurements/models/measurement_entry.freezed.dart b/lib/features/measurements/models/measurement_entry.freezed.dart index e4423aa6a..84bb4fea5 100644 --- a/lib/features/measurements/models/measurement_entry.freezed.dart +++ b/lib/features/measurements/models/measurement_entry.freezed.dart @@ -15,7 +15,11 @@ T _$identity(T value) => value; mixin _$MeasurementEntry { /// Client-generated UUID, is `null` only before the first persist - String? get id; String get categoryId; DateTime get date; num get value; String get notes; + String? get id; String get categoryId; DateTime get date; num get value; String get notes;/// Origin of the reading; one of the server's `source` values +/// (`user`, `google`, `apple`). + String get source;/// Platform record UUID, used to deduplicate re-imports. `null` for manual +/// entries. + String? get externalId; /// Create a copy of MeasurementEntry /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +30,16 @@ $MeasurementEntryCopyWith get copyWith => _$MeasurementEntryCo @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.categoryId, categoryId) || other.categoryId == categoryId)&&(identical(other.date, date) || other.date == date)&&(identical(other.value, value) || other.value == value)&&(identical(other.notes, notes) || other.notes == notes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MeasurementEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.categoryId, categoryId) || other.categoryId == categoryId)&&(identical(other.date, date) || other.date == date)&&(identical(other.value, value) || other.value == value)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.source, source) || other.source == source)&&(identical(other.externalId, externalId) || other.externalId == externalId)); } @override -int get hashCode => Object.hash(runtimeType,id,categoryId,date,value,notes); +int get hashCode => Object.hash(runtimeType,id,categoryId,date,value,notes,source,externalId); @override String toString() { - return 'MeasurementEntry(id: $id, categoryId: $categoryId, date: $date, value: $value, notes: $notes)'; + return 'MeasurementEntry(id: $id, categoryId: $categoryId, date: $date, value: $value, notes: $notes, source: $source, externalId: $externalId)'; } @@ -46,7 +50,7 @@ abstract mixin class $MeasurementEntryCopyWith<$Res> { factory $MeasurementEntryCopyWith(MeasurementEntry value, $Res Function(MeasurementEntry) _then) = _$MeasurementEntryCopyWithImpl; @useResult $Res call({ - String? id, String categoryId, DateTime date, num value, String notes + String? id, String categoryId, DateTime date, num value, String notes, String source, String? externalId }); @@ -63,14 +67,16 @@ class _$MeasurementEntryCopyWithImpl<$Res> /// Create a copy of MeasurementEntry /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? categoryId = null,Object? date = null,Object? value = null,Object? notes = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? categoryId = null,Object? date = null,Object? value = null,Object? notes = null,Object? source = null,Object? externalId = freezed,}) { return _then(MeasurementEntry( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,categoryId: null == categoryId ? _self.categoryId : categoryId // ignore: cast_nullable_to_non_nullable as String,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable as DateTime,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable as num,notes: null == notes ? _self.notes : notes // ignore: cast_nullable_to_non_nullable -as String, +as String,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable +as String,externalId: freezed == externalId ? _self.externalId : externalId // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/features/measurements/providers/measurement_notifier.dart b/lib/features/measurements/providers/measurement_notifier.dart index 8c8f6471f..2f86c12ca 100644 --- a/lib/features/measurements/providers/measurement_notifier.dart +++ b/lib/features/measurements/providers/measurement_notifier.dart @@ -72,6 +72,12 @@ final class MeasurementNotifier extends _$MeasurementNotifier { await _repo.addLocalDrift(entry); } + /// Adds one reading of a multi-value group (one entry per component, + /// persisted atomically). + Future addGroupEntries(List entries) async { + await _repo.addLocalDriftGroupEntries(entries); + } + // --- MeasurementCategory operations (delegated to repository) --- Future deleteCategory(String id) async { await _repo.deleteLocalDriftCategory(id); @@ -84,4 +90,22 @@ final class MeasurementNotifier extends _$MeasurementNotifier { Future addCategory(MeasurementCategory category) async { await _repo.addLocalDriftCategory(category); } + + /// Moves the top-level category at [oldIndex] to [newIndex] and renumbers + /// all top-level categories accordingly. + /// + /// Indices refer to the top-level list only (children of multi-value groups + /// keep their in-group order). + Future setCategoryOrder(int oldIndex, int newIndex) async { + final categories = state.asData?.value; + if (categories == null) { + return; + } + + final reordered = categories.where((c) => c.parentId == null).toList(); + final moved = reordered.removeAt(oldIndex); + reordered.insert(newIndex, moved); + + await _repo.reorderCategories([for (final c in reordered) c.id!]); + } } diff --git a/lib/features/measurements/providers/measurement_notifier.g.dart b/lib/features/measurements/providers/measurement_notifier.g.dart index 598a5a4c9..561817c74 100644 --- a/lib/features/measurements/providers/measurement_notifier.g.dart +++ b/lib/features/measurements/providers/measurement_notifier.g.dart @@ -33,7 +33,7 @@ final class MeasurementNotifierProvider MeasurementNotifier create() => MeasurementNotifier(); } -String _$measurementNotifierHash() => r'94978ed841f94d3481322dc144ef82da95cfb62c'; +String _$measurementNotifierHash() => r'2072d9a5dd3b0c64830913541d236f5dfe28fbbc'; abstract class _$MeasurementNotifier extends $StreamNotifier> { Stream> build(); diff --git a/lib/features/measurements/providers/measurement_repository.dart b/lib/features/measurements/providers/measurement_repository.dart index 5763e6e03..fa24db21c 100644 --- a/lib/features/measurements/providers/measurement_repository.dart +++ b/lib/features/measurements/providers/measurement_repository.dart @@ -39,7 +39,11 @@ class MeasurementRepository { MeasurementRepository(this._db); - /// Watches all categories, populated with their appropriate entries + /// Watches all categories, populated with their appropriate entries. + /// + /// The list is flat (children of multi-value groups appear as regular items + /// with a non-null `parentId`), but group parents additionally get their + /// [MeasurementCategory.children] attached, sorted by their in-group order. Stream> watchAll() { _logger.finer('Watching all measurement categories with entries'); @@ -50,6 +54,7 @@ class MeasurementRepository { _db.measurementEntryTable.categoryId.equalsExp(_db.measurementCategoryTable.id), ), ])..orderBy([ + OrderingTerm(expression: _db.measurementCategoryTable.order), OrderingTerm(expression: _db.measurementCategoryTable.name), OrderingTerm(expression: _db.measurementEntryTable.date, mode: OrderingMode.desc), ]); @@ -71,9 +76,16 @@ class MeasurementRepository { } } - return map.values + final categories = map.values .map((c) => c.copyWith(entries: List.from(c.entries))) .toList(); + + // Attach children to their group parents (rows are already sorted by + // order/name, so insertion order is the display order). + return categories.map((c) { + final children = categories.where((other) => other.parentId == c.id).toList(); + return children.isEmpty ? c : c.copyWith(children: children); + }).toList(); }); } @@ -82,6 +94,9 @@ class MeasurementRepository { return watchAll().map((categories) => categories.firstWhereOrNull((c) => c.id == id)); } + /// One-shot snapshot of all categories with their entries. + Future> getAllOnce() => watchAll().first; + // Entries Future deleteLocalDrift(String id) async { _logger.finer('Deleting local measurement entry $id'); @@ -99,10 +114,26 @@ class MeasurementRepository { await _db.into(_db.measurementEntryTable).insert(entry.toCompanion()); } + /// Inserts one reading of a multi-value group: one entry per component, + /// written in a single transaction so a reading is never half-persisted. + Future addLocalDriftGroupEntries(List entries) async { + _logger.finer('Adding ${entries.length} local measurement entries for a group reading'); + await _db.transaction(() async { + for (final entry in entries) { + await _db.into(_db.measurementEntryTable).insert(entry.toCompanion()); + } + }); + } + // Categories Future deleteLocalDriftCategory(String id) async { _logger.finer('Deleting local measurement category $id'); - await (_db.delete(_db.measurementCategoryTable)..where((t) => t.id.equals(id))).go(); + await _db.transaction(() async { + // Children of a multi-value group are meaningless without their parent; + // the server cascades the same way. + await (_db.delete(_db.measurementCategoryTable)..where((t) => t.parentId.equals(id))).go(); + await (_db.delete(_db.measurementCategoryTable)..where((t) => t.id.equals(id))).go(); + }); } Future updateLocalDriftCategory(MeasurementCategory category) async { @@ -115,4 +146,18 @@ class MeasurementRepository { _logger.finer('Adding local measurement category ${category.name}'); await _db.into(_db.measurementCategoryTable).insert(category.toCompanion()); } + + /// Persists the given display order: each category gets its list index as + /// [MeasurementCategory.order]. Categories whose order is unchanged are not + /// written, so no sync upload is queued for them. + Future reorderCategories(List orderedIds) async { + _logger.finer('Reordering ${orderedIds.length} categories'); + await _db.transaction(() async { + for (var i = 0; i < orderedIds.length; i++) { + final stmt = _db.update(_db.measurementCategoryTable) + ..where((t) => t.id.equals(orderedIds[i]) & t.order.equals(i).not()); + await stmt.write(MeasurementCategoryTableCompanion(order: Value(i))); + } + }); + } } diff --git a/lib/features/measurements/screens/measurement_categories_screen.dart b/lib/features/measurements/screens/measurement_categories_screen.dart index 985f020fd..51393e67b 100644 --- a/lib/features/measurements/screens/measurement_categories_screen.dart +++ b/lib/features/measurements/screens/measurement_categories_screen.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:wger/core/form_screen.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; import 'package:wger/features/measurements/widgets/categories.dart'; import 'package:wger/features/measurements/widgets/forms.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -30,8 +31,19 @@ class MeasurementCategoriesScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context).measurements)), + appBar: AppBar( + title: Text(AppLocalizations.of(context).measurements), + actions: [ + IconButton( + icon: const Icon(Icons.sort), + tooltip: i18n.reorderCategories, + onPressed: () => + Navigator.of(context).pushNamed(MeasurementCategorySortScreen.routeName), + ), + ], + ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add, color: Colors.white), onPressed: () { @@ -39,7 +51,7 @@ class MeasurementCategoriesScreen extends StatelessWidget { context, FormScreen.routeName, arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, + i18n.newEntry, const MeasurementCategoryForm(), ), ); diff --git a/lib/features/measurements/screens/measurement_category_sort_screen.dart b/lib/features/measurements/screens/measurement_category_sort_screen.dart new file mode 100644 index 000000000..96675dce8 --- /dev/null +++ b/lib/features/measurements/screens/measurement_category_sort_screen.dart @@ -0,0 +1,79 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/core/widgets/async_value_widget.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/providers/measurement_notifier.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +/// Drag-and-drop reordering of the top-level measurement categories. +/// +/// Children of multi-value groups are not listed; they keep their in-group +/// order and follow their parent. +class MeasurementCategorySortScreen extends ConsumerWidget { + static const routeName = '/measurement-categories-sort'; + + const MeasurementCategorySortScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final i18n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(i18n.reorderCategories), + ), + body: WidescreenWrapper( + child: AsyncValueWidget>( + value: ref.watch(measurementProvider), + loggerName: 'MeasurementCategorySortScreen', + data: (categories) => _SortableList(categories.where((c) => c.parentId == null).toList()), + ), + ), + ); + } +} + +class _SortableList extends ConsumerWidget { + final List _categories; + const _SortableList(this._categories); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(measurementProvider.notifier); + return ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: _categories.length, + itemBuilder: (context, index) { + final category = _categories[index]; + return ListTile( + key: ValueKey(category.id), + title: Text(category.name), + subtitle: Text(category.unit), + trailing: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + ); + }, + onReorderItem: notifier.setCategoryOrder, + ); + } +} diff --git a/lib/features/measurements/widgets/categories.dart b/lib/features/measurements/widgets/categories.dart index 585f6a631..319d5ff84 100644 --- a/lib/features/measurements/widgets/categories.dart +++ b/lib/features/measurements/widgets/categories.dart @@ -31,11 +31,17 @@ class CategoriesList extends ConsumerWidget { return AsyncValueWidget>( value: ref.watch(measurementProvider), loggerName: 'CategoriesList', - data: (categoriesList) => ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: categoriesList.length, - itemBuilder: (context, index) => CategoriesCard(categoriesList[index]), - ), + data: (categoriesList) { + // Children of multi-value groups are rendered inside their parent's + // card, not as own list items + final topLevel = categoriesList.where((c) => c.parentId == null).toList(); + + return ListView.builder( + padding: const EdgeInsets.all(10.0), + itemCount: topLevel.length, + itemBuilder: (context, index) => CategoriesCard(topLevel[index]), + ); + }, ); } } diff --git a/lib/features/measurements/widgets/categories_card.dart b/lib/features/measurements/widgets/categories_card.dart index b0d798ff1..3afff5ece 100644 --- a/lib/features/measurements/widgets/categories_card.dart +++ b/lib/features/measurements/widgets/categories_card.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:wger/core/form_screen.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; @@ -34,6 +35,11 @@ class CategoriesCard extends StatelessWidget { @override Widget build(BuildContext context) { + if (currentCategory.isGroup) { + return _buildGroupCard(context); + } + + // sensibleRange() operates on raw (pre-aggregation) entries final (entriesAll, entries7dAvg) = sensibleRange( currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(), ); @@ -57,13 +63,14 @@ class CategoriesCard extends StatelessWidget { Container( padding: const EdgeInsets.all(10), height: 220, - child: MeasurementChartWidgetFl( + child: buildChartForMetricType( + currentCategory.metricType, entriesAll, + entries7dAvg, currentCategory.unit, - avgs: entries7dAvg, ), ), - if (entries7dAvg.isNotEmpty) + if (entries7dAvg.isNotEmpty && !currentCategory.metricType.isSummedPerDay) MeasurementOverallChangeWidget( entries7dAvg.first, entries7dAvg.last, @@ -113,4 +120,62 @@ class CategoriesCard extends StatelessWidget { ), ); } + + /// Card for a multi-value group (e.g. blood pressure): one row per + /// component with its latest reading; new readings are entered for all + /// components at once. + Widget _buildGroupCard(BuildContext context) { + return Card( + elevation: elevation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + currentCategory.name, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ...currentCategory.children.map((child) { + // Entries arrive sorted by date descending, so first is the latest + final latest = child.entries.firstOrNull; + return ListTile( + dense: true, + title: Text(child.name), + trailing: Text( + latest != null ? '${latest.value} ${child.unit}' : '—', + style: Theme.of(context).textTheme.bodyLarge, + ), + onTap: () => Navigator.pushNamed( + context, + MeasurementEntriesScreen.routeName, + arguments: child.id, + ), + ); + }), + const Divider(), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () async { + await Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + GroupMeasurementEntryForm(currentCategory), + ), + ); + }, + icon: const Icon(Icons.add), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/measurements/widgets/charts.dart b/lib/features/measurements/widgets/charts.dart index 4ab64ef3c..8d43749f5 100644 --- a/lib/features/measurements/widgets/charts.dart +++ b/lib/features/measurements/widgets/charts.dart @@ -55,9 +55,10 @@ String weightUnit(bool isMetric, BuildContext context) { class MeasurementChartWidgetFl extends StatefulWidget { final List _entries; final List? avgs; + final List? trend; final String _unit; - const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs}); + const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs, this.trend}); @override State createState() => _MeasurementChartWidgetFlState(); @@ -210,6 +211,17 @@ class _MeasurementChartWidgetFlState extends State { barWidth: 1, dotData: const FlDotData(show: false), ), + if (widget.trend != null && widget.trend!.isNotEmpty) + LineChartBarData( + spots: widget.trend! + .map((e) => FlSpot(e.date.millisecondsSinceEpoch.toDouble(), e.value.toDouble())) + .toList(), + isCurved: true, + curveSmoothness: 0.4, + color: Theme.of(context).colorScheme.secondary, + barWidth: 3, + dotData: const FlDotData(show: false), + ), ], ); } @@ -250,6 +262,168 @@ List moving7dAverage(List vals) { return out; } +/// Sums entries per calendar day. Used for metric types where individual +/// samples aren't meaningful on their own (steps, distance, energy, sleep) — +/// see MeasurementMetricType.isSummedPerDay. +List aggregatePerDay(List vals) { + if (vals.isEmpty) { + return []; + } + + // Bucket by the day component only (strip time-of-day). + final Map sums = {}; + for (final e in vals) { + final day = DateTime(e.date.year, e.date.month, e.date.day); + sums.update(day, (existing) => existing + e.value, ifAbsent: () => e.value); + } + + final out = sums.entries.map((e) => MeasurementChartEntry(e.value, e.key)).toList() + ..sort((a, b) => a.date.compareTo(b.date)); + return out; +} + +class MeasurementBarChartWidgetFl extends StatefulWidget { + final List _entries; + final String _unit; + + const MeasurementBarChartWidgetFl(this._entries, this._unit); + + @override + State createState() => _MeasurementBarChartWidgetFlState(); +} + +class _MeasurementBarChartWidgetFlState extends State { + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1.70, + child: Padding( + padding: const EdgeInsets.all(4), + child: BarChart(mainData()), + ), + ); + } + + BarTouchData tooltipData() { + return BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (group) => Theme.of(context).colorScheme.primaryContainer, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final DateTime date = DateTime.fromMillisecondsSinceEpoch(group.x); + final dateStr = DateFormat.Md(Localizations.localeOf(context).languageCode).format(date); + + return BarTooltipItem( + '$dateStr: ${numberFormat.format(rod.toY)} ${widget._unit}', + TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer), + ); + }, + ), + ); + } + + BarChartData mainData() { + final String locale = Localizations.localeOf(context).toString(); + final NumberFormat numberFormat = NumberFormat.decimalPattern(locale); + return BarChartData( + barTouchData: tooltipData(), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) { + return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1); + }, + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + // avoid label overlap with the axis edges + if (value == meta.min || value == meta.max) { + return const Text(''); + } + final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); + return Text(DateFormat.Md(Localizations.localeOf(context).languageCode).format(date)); + }, + interval: widget._entries.isNotEmpty + ? chartGetInterval(widget._entries.last.date, widget._entries.first.date) + : CHART_MILLISECOND_FACTOR, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 65, + getTitlesWidget: (value, meta) { + if (value == meta.min || value == meta.max) { + return const Text(''); + } + return Text('${numberFormat.format(value)} ${widget._unit}'); + }, + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border.all(color: Theme.of(context).colorScheme.primaryContainer), + ), + barGroups: widget._entries + .map( + (e) => BarChartGroupData( + x: e.date.millisecondsSinceEpoch, + barRods: [ + BarChartRodData( + toY: e.value.toDouble(), + color: Theme.of(context).colorScheme.primary, + width: 12, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + ), + ], + ), + ) + .toList(), + ); + } +} + +/// Produces a smoothed trendline via an Exponential Moving Average (EMA), +/// seeded with the first data point. A larger [period] tracks the raw values +/// more loosely (smoother, more lag). +List smoothedTrendline( + List vals, { + int period = 10, +}) { + if (vals.isEmpty) { + return []; + } + + final sorted = [...vals]..sort((a, b) => a.date.compareTo(b.date)); + final List out = []; + + // Seed the initialization layer with the first data point value + double currentEma = sorted[0].value.toDouble(); + final double smoothing = 2 / (period + 1); + + for (int i = 0; i < sorted.length; i++) { + final point = sorted[i]; + + if (i > 0) { + // EMA equation: point.weight * smoothing + ema * (1 - smoothing) + currentEma = (point.value * smoothing) + (currentEma * (1 - smoothing)); + } + + out.add(MeasurementChartEntry(currentEma, point.date)); + } + + return out; +} + class Indicator extends StatelessWidget { const Indicator({ super.key, diff --git a/lib/features/measurements/widgets/entries.dart b/lib/features/measurements/widgets/entries.dart index 9d4906fcd..eae75d3d1 100644 --- a/lib/features/measurements/widgets/entries.dart +++ b/lib/features/measurements/widgets/entries.dart @@ -57,6 +57,7 @@ class EntriesList extends ConsumerWidget { plans, _category.unit, context, + metricType: _category.metricType, ), SizedBox( height: 300, diff --git a/lib/features/measurements/widgets/forms.dart b/lib/features/measurements/widgets/forms.dart index d0543300c..18ab82446 100644 --- a/lib/features/measurements/widgets/forms.dart +++ b/lib/features/measurements/widgets/forms.dart @@ -95,6 +95,69 @@ class _MeasurementCategoryFormState extends ConsumerState DropdownMenuItem(value: t, child: Text(t.localized(context)))) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _draft = _draft.copyWith(metricType: value); + }); + } + }, + ), + + // Parent group (multi-value measurements, e.g. blood pressure). + // Mirrors the server rules: only top-level, entry-free categories + // can be parents, and a category with children cannot be nested. + Builder( + builder: (context) { + final categories = ref.watch(measurementProvider).asData?.value ?? []; + + final hasChildren = + _draft.id != null && categories.any((c) => c.parentId == _draft.id); + if (hasChildren) { + return const SizedBox.shrink(); + } + + final candidates = categories + .where((c) => c.parentId == null && c.id != _draft.id && c.entries.isEmpty) + .toList(); + if (candidates.isEmpty) { + return const SizedBox.shrink(); + } + + final initialParent = candidates.any((c) => c.id == _draft.parentId) + ? _draft.parentId + : null; + + return DropdownButtonFormField( + initialValue: initialParent, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).partOfGroup, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(AppLocalizations.of(context).noGroup), + ), + ...candidates.map( + (c) => DropdownMenuItem(value: c.id, child: Text(c.name)), + ), + ], + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(parentId: value); + }); + }, + ); + }, + ), FormSubmitButton( label: AppLocalizations.of(context).save, onPressed: () async { @@ -272,3 +335,97 @@ class _MeasurementEntryFormState extends ConsumerState { ); } } + +/// Entry form for a multi-value group (e.g. blood pressure): one value field +/// per component, saved as one entry per component with a shared timestamp. +class GroupMeasurementEntryForm extends ConsumerStatefulWidget { + final MeasurementCategory _group; + + const GroupMeasurementEntryForm(this._group); + + @override + ConsumerState createState() => _GroupMeasurementEntryFormState(); +} + +class _GroupMeasurementEntryFormState extends ConsumerState { + final _form = GlobalKey(); + + DateTime _date = DateTime.now(); + late final Map _values = { + for (final child in widget._group.children) child.id!: null, + }; + + @override + Widget build(BuildContext context) { + return Form( + key: _form, + child: Column( + children: [ + // Date and time are shared by all components of the reading + DateInputWidget( + value: _date, + labelText: AppLocalizations.of(context).date, + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + onChanged: (date) { + _date = _date.copyWith( + year: date.year, + month: date.month, + day: date.day, + ); + }, + ), + TimeInputWidget( + value: TimeOfDay.fromDateTime(_date), + labelText: AppLocalizations.of(context).time, + onChanged: (time) { + _date = _date.copyWith( + hour: time.hour, + minute: time.minute, + second: 0, + ); + }, + ), + + // One value field per component + for (final child in widget._group.children) + DecimalInputWidget( + value: _values[child.id], + labelText: child.name, + suffixText: child.unit.isNotEmpty ? child.unit : widget._group.unit, + isRequired: true, + min: MeasurementEntry.minValue, + max: MeasurementEntry.maxValue, + onChanged: (value) => _values[child.id!] = value, + ), + + FormSubmitButton( + label: AppLocalizations.of(context).save, + onPressed: () async { + if (!_form.currentState!.validate()) { + return; + } + _form.currentState!.save(); + + final entries = widget._group.children + .map( + (child) => MeasurementEntry( + categoryId: child.id!, + date: _date, + value: _values[child.id]!, + notes: '', + ), + ) + .toList(); + await ref.read(measurementProvider.notifier).addGroupEntries(entries); + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/measurements/widgets/helpers.dart b/lib/features/measurements/widgets/helpers.dart index d019a920d..456282c93 100644 --- a/lib/features/measurements/widgets/helpers.dart +++ b/lib/features/measurements/widgets/helpers.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wger/features/measurements/measurements.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; import 'package:wger/features/measurements/widgets/charts.dart'; import 'package:wger/features/nutrition/models/nutritional_plan.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -9,8 +10,9 @@ List getOverviewWidgets( List raw, List avg, String unit, - BuildContext context, -) { + BuildContext context, { + MetricType metricType = MetricType.custom, +}) { return [ Text( title, @@ -29,7 +31,7 @@ List getOverviewWidgets( ), ), ) - : MeasurementChartWidgetFl(raw, unit, avgs: avg), + : buildChartForMetricType(metricType, raw, avg, unit), ), if (avg.isNotEmpty) MeasurementOverallChangeWidget(avg.first, avg.last, unit), const SizedBox(height: 8), @@ -42,8 +44,9 @@ List getOverviewWidgetsSeries( List entries7dAvg, List plans, String unit, - BuildContext context, -) { + BuildContext context, { + MetricType metricType = MetricType.custom, +}) { final monthAgo = DateTime.now().subtract(const Duration(days: 30)); return [ ...getOverviewWidgets( @@ -52,6 +55,7 @@ List getOverviewWidgetsSeries( entries7dAvg, unit, context, + metricType: metricType, ), // Show overview widgets for each plan in plans for (final plan in plans) @@ -61,6 +65,7 @@ List getOverviewWidgetsSeries( entries7dAvg.whereDateWithInterpolation(plan.startDate, plan.endDate), unit, context, + metricType: metricType, ), // if all time is significantly longer than 30 days (let's say > 75 days) // then let's show a separate chart just focusing on the last 30 days, @@ -74,6 +79,7 @@ List getOverviewWidgetsSeries( entries7dAvg.whereDateWithInterpolation(monthAgo, null), unit, context, + metricType: metricType, ), // legend Row( @@ -89,6 +95,12 @@ List getOverviewWidgetsSeries( text: AppLocalizations.of(context).indicatorAvg, isSquare: true, ), + if (!metricType.isSummedPerDay) + Indicator( + color: Theme.of(context).colorScheme.secondary, + text: AppLocalizations.of(context).indicatorTrend, + isSquare: true, + ), ], ), ]; @@ -120,3 +132,23 @@ List getOverviewWidgetsSeries( } return (entriesAll, entries7dAvg); } + +Widget buildChartForMetricType( + MetricType metricType, + List raw, + List avg, + String unit, +) { + if (metricType.isSummedPerDay) { + return MeasurementBarChartWidgetFl(aggregatePerDay(raw), unit); + } + // if (metricType.isRangeType) { + // return buildRangeChartForMetricType(metricType, raw, unit); + // } + return MeasurementChartWidgetFl( + raw, + unit, + avgs: avg, + trend: smoothedTrendline(raw), + ); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 84bb6786b..1204cf164 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -837,6 +837,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "Trend", "themeMode": "Theme Modus", "lightMode": "Immer Hellmodus", "systemMode": "Systemeinstellungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91e0c8891..b45efdef0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -542,8 +542,64 @@ }, "measurementCategoriesHelpText": "Measurement category, such as 'biceps' or 'body fat'", "@measurementCategoriesHelpText": {}, + "metricType": "Metric Type", + "@metricType": { + "description": "Metric type for a measurement category" + }, + "reorderCategories": "Reorder categories", + "@reorderCategories": { + "description": "measurement categories reordering" + }, + "metricCustom": "Custom", + "@metricCustom": { + "description": "Label for custom metric type" + }, + "metricBodyWeight": "Body weight", + "@metricBodyWeight": { + "description": "Label for body weight measurement" + }, + "metricBodyFat": "Body fat", + "@metricBodyFat": { + "description": "Label for body fat percentage measurement" + }, + "metricHeight": "Height", + "@metricHeight": { + "description": "Label for height measurement" + }, + "metricBloodPressure": "Blood pressure", + "@metricBloodPressure": { + "description": "Label for blood pressure measurement" + }, + "metricHeartRate": "Heart rate", + "@metricHeartRate": { + "description": "Label for heart rate measurement" + }, + "metricSteps": "Steps", + "@metricSteps": { + "description": "Label for step count measurement" + }, + "metricDistance": "Distance", + "@metricDistance": { + "description": "Label for distance measurement" + }, + "metricEnergy": "Energy", + "@metricEnergy": { + "description": "Label for energy/calories burned measurement" + }, + "metricSleep": "Sleep", + "@metricSleep": { + "description": "Label for sleep duration measurement" + }, "measurementEntriesHelpText": "The unit used to measure the category such as 'cm' or '%'", "@measurementEntriesHelpText": {}, + "partOfGroup": "Part of group", + "@partOfGroup": { + "description": "Dropdown label: makes a measurement category a component of a multi-value group (e.g. blood pressure)" + }, + "noGroup": "No group", + "@noGroup": { + "description": "Dropdown option for a measurement category that is not part of any group" + }, "date": "Date", "@date": { "description": "The date of a workout log or body weight entry" @@ -1274,6 +1330,10 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "trend", + "@indicatorTrend": { + "description": "Legend label for the smoothed trendline on measurement charts" + }, "endWorkout": "End workout", "@endWorkout": { "description": "Use the imperative, label on button to finish the current workout in gym mode" @@ -1434,5 +1494,26 @@ "timeStartEndBothOrNeither": "Either set both start and end time or leave both empty", "@timeStartEndBothOrNeither": { "description": "Validation error when only one of start/end time is set on a workout session" + }, + "healthSync": "Health sync", + "@healthSync": { + "description": "Title for the health platform sync setting" + }, + "healthSyncDescription": "Import body measurements from Apple Health or Health Connect", + "@healthSyncDescription": { + "description": "Subtitle for the health platform sync setting" + }, + "healthSyncSuccess": "Imported {count} measurements 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/l10n/app_es.arb b/lib/l10n/app_es.arb index f7063822d..7ec8167a6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -838,6 +838,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "tendencia", "themeMode": "Modo del tema", "darkMode": "Siempre en modo oscuro", "lightMode": "Siempre en modo claro", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 13406e317..0f1c4343e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -761,6 +761,7 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, + "indicatorTrend": "tendance", "resistance_band": "Bande de résistance", "@resistance_band": { "description": "Generated entry for translation for server strings" diff --git a/lib/main.dart b/lib/main.dart index 74d053ce9..2a5cb29ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,7 @@ import 'package:wger/features/exercises/screens/exercise_screen.dart'; import 'package:wger/features/exercises/screens/exercises_screen.dart'; import 'package:wger/features/gallery/screens/gallery_screen.dart'; import 'package:wger/features/measurements/screens/measurement_categories_screen.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; import 'package:wger/features/measurements/screens/measurement_entries_screen.dart'; import 'package:wger/features/nutrition/screens/ingredient_detail_screen.dart'; import 'package:wger/features/nutrition/screens/ingredients_screen.dart'; @@ -206,6 +207,7 @@ class MainApp extends ConsumerWidget { GymModeScreen.routeName: (ctx) => const GymModeScreen(), HomeTabsScreen.routeName: (ctx) => const HomeTabsScreen(), MeasurementCategoriesScreen.routeName: (ctx) => const MeasurementCategoriesScreen(), + MeasurementCategorySortScreen.routeName: (ctx) => const MeasurementCategorySortScreen(), MeasurementEntriesScreen.routeName: (ctx) => const MeasurementEntriesScreen(), NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), diff --git a/lib/powersync/connector.dart b/lib/powersync/connector.dart index 532c15b74..17424003c 100644 --- a/lib/powersync/connector.dart +++ b/lib/powersync/connector.dart @@ -157,7 +157,10 @@ class DjangoConnector extends PowerSyncBackendConnector { if (k == 'id') { return; } - final key = k.endsWith('_id') ? k.substring(0, k.length - 3) : k; + // Trailing `_id` marks a foreign key, which Django exposes without the + // suffix (`category_id` -> `category`). `external_id` is a plain value + // column, not an FK, so it keeps its name. + final key = (k.endsWith('_id') && k != 'external_id') ? k.substring(0, k.length - 3) : k; out[key] = (dateFields.contains(key) && v is String && v.length >= 10) ? v.substring(0, 10) : v; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3a1b4aadc..d50a5f8c2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import app_links import connectivity_plus +import device_info_plus import file_selector_macos import flutter_secure_storage_darwin import package_info_plus @@ -17,6 +18,7 @@ import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7c7ed5f1b..224fca8d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -361,6 +361,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.13" + device_info_plus: + dependency: "direct overridden" + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" drift: dependency: "direct main" description: @@ -719,6 +735,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + health_bridge: + dependency: "direct main" + description: + path: "." + ref: "51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1" + resolved-ref: "51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1" + url: "https://github.com/ytsni/health_bridge.git" + source: git + version: "1.0.0" hooks: dependency: transitive description: @@ -1801,6 +1826,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 75d53ab4f..8ddb61f9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,15 @@ dependencies: flutter_zxing: ^2.3.0 font_awesome_flutter: ^11.0.0 freezed_annotation: ^3.1.0 + # Temporarily swap `health` with a fork with Swift Package Manager support. + # Once that is available on upstream (see e.g. https://github.com/carp-dk/carp-health-flutter/issues/480 ) + # we can switch back. Note that if this doesn't happen in the near future we + # can just re-activate the cocoapods infrastructure. + # health: ^13.3.1 + health_bridge: + git: + url: https://github.com/ytsni/health_bridge.git + ref: 51a567db9a97ccb15a9c1138aa4bcbdaf3d6c7b1 http: ^1.6.0 image_picker: ^1.2.2 intl: ^0.20.2 @@ -74,6 +83,12 @@ dependencies: connectivity_plus: ^7.2.0 powersync: ^2.3.0 +dependency_overrides: + # health -> device_info_plus ^12.1.0 pins win32 5.x, which conflicts with + # package_info_plus 10.2.0 (win32 ^6). device_info_plus 13.x is a pure win32 + # bump with no Dart API change, so it satisfies health while keeping win32 6. + device_info_plus: ^13.0.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 7030eb892..ac8462028 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -2019,6 +2019,127 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get metricType => + (super.noSuchMethod( + Invocation.getter(#metricType), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricType), + ), + ) + as String); + + @override + String get metricCustom => + (super.noSuchMethod( + Invocation.getter(#metricCustom), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricCustom), + ), + ) + as String); + + @override + String get metricBodyWeight => + (super.noSuchMethod( + Invocation.getter(#metricBodyWeight), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricBodyWeight), + ), + ) + as String); + + @override + String get metricBodyFat => + (super.noSuchMethod( + Invocation.getter(#metricBodyFat), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricBodyFat), + ), + ) + as String); + + @override + String get metricHeight => + (super.noSuchMethod( + Invocation.getter(#metricHeight), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricHeight), + ), + ) + as String); + + @override + String get metricBloodPressure => + (super.noSuchMethod( + Invocation.getter(#metricBloodPressure), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricBloodPressure), + ), + ) + as String); + + @override + String get metricHeartRate => + (super.noSuchMethod( + Invocation.getter(#metricHeartRate), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricHeartRate), + ), + ) + as String); + + @override + String get metricSteps => + (super.noSuchMethod( + Invocation.getter(#metricSteps), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricSteps), + ), + ) + as String); + + @override + String get metricDistance => + (super.noSuchMethod( + Invocation.getter(#metricDistance), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricDistance), + ), + ) + as String); + + @override + String get metricEnergy => + (super.noSuchMethod( + Invocation.getter(#metricEnergy), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricEnergy), + ), + ) + as String); + + @override + String get metricSleep => + (super.noSuchMethod( + Invocation.getter(#metricSleep), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#metricSleep), + ), + ) + as String); + @override String get measurementEntriesHelpText => (super.noSuchMethod( @@ -2030,6 +2151,28 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get partOfGroup => + (super.noSuchMethod( + Invocation.getter(#partOfGroup), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#partOfGroup), + ), + ) + as String); + + @override + String get noGroup => + (super.noSuchMethod( + Invocation.getter(#noGroup), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#noGroup), + ), + ) + as String); + @override String get date => (super.noSuchMethod( @@ -4140,6 +4283,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get indicatorTrend => + (super.noSuchMethod( + Invocation.getter(#indicatorTrend), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#indicatorTrend), + ), + ) + as String); + @override String get endWorkout => (super.noSuchMethod( @@ -4547,6 +4701,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get healthSync => + (super.noSuchMethod( + Invocation.getter(#healthSync), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#healthSync), + ), + ) + as String); + + @override + String get healthSyncDescription => + (super.noSuchMethod( + Invocation.getter(#healthSyncDescription), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#healthSyncDescription), + ), + ) + as String); + + @override + String get health => + (super.noSuchMethod( + Invocation.getter(#health), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#health), + ), + ) + as String); + @override String exerciseNr(String? nr) => (super.noSuchMethod( @@ -4898,4 +5085,15 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ), ) as String); + + @override + String healthSyncSuccess(int? count) => + (super.noSuchMethod( + Invocation.method(#healthSyncSuccess, [count]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#healthSyncSuccess, [count]), + ), + ) + as String); } diff --git a/test/features/account/widgets/settings_test.dart b/test/features/account/widgets/settings_test.dart index 3271554ec..d6bce6230 100644 --- a/test/features/account/widgets/settings_test.dart +++ b/test/features/account/widgets/settings_test.dart @@ -25,10 +25,21 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wger/core/app_settings_notifier.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/features/account/widgets/settings.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'settings_test.mocks.dart'; +/// Keeps the health sync tile inert (reports the platform as unavailable) so +/// the settings page renders without booting the real health/preferences stack. +class _FakeHealthSyncNotifier extends HealthSyncNotifier { + @override + HealthSyncState build() => const HealthSyncState(); + + @override + Future isAvailable() async => false; +} + @GenerateMocks([ SharedPreferencesAsync, ]) @@ -53,6 +64,7 @@ void main() { return riverpod.ProviderScope( overrides: [ appSettingsPrefsProvider.overrideWithValue(mockSharedPreferences), + healthSyncProvider.overrideWith(_FakeHealthSyncNotifier.new), ], child: MaterialApp( locale: Locale(locale), diff --git a/test/features/health/models/health_metric_test.dart b/test/features/health/models/health_metric_test.dart new file mode 100644 index 000000000..b1e14f38e --- /dev/null +++ b/test/features/health/models/health_metric_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:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/features/health/models/health_metric.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; + +HealthMetric _metric(MetricType type) => healthMetrics.firstWhere((m) => m.metricType == type); + +void main() { + group('Enabled metric set', () { + test('only body fat and height are imported in V1', () { + expect( + enabledHealthMetrics.map((m) => m.metricType), + containsAll([MetricType.bodyFat, MetricType.height]), + ); + expect(enabledHealthMetrics.length, 2); + }); + + test('every disabled metric explains why', () { + final disabled = healthMetrics.where((m) => !m.enabled); + expect(disabled, isNotEmpty); + expect(disabled.every((m) => m.disabledReason != null), isTrue); + }); + + test('metric types are unique', () { + final types = healthMetrics.map((m) => m.metricType).toList(); + expect(types.length, types.toSet().length); + }); + }); + + group('Body fat conversion', () { + final bodyFat = _metric(MetricType.bodyFat); + + test('iOS fraction is scaled to a percentage', () { + expect(bodyFat.toCategoryValue(0.15), closeTo(15, 0.001)); + }); + + test('Health Connect percentage is kept as-is', () { + expect(bodyFat.toCategoryValue(15), closeTo(15, 0.001)); + }); + }); + + group('Height conversion', () { + final height = _metric(MetricType.height); + + test('meters are converted to centimeters', () { + expect(height.toCategoryValue(1.75), closeTo(175, 0.001)); + }); + + test('a value already in centimeters is kept as-is', () { + expect(height.toCategoryValue(180), closeTo(180, 0.001)); + }); + }); + + test('enabledHealthMetrics matches the enabled flag', () { + expect( + enabledHealthMetrics, + healthMetrics.where((m) => m.enabled).toList(), + ); + // sanity: the disabled ones really are excluded + expect(enabledHealthMetrics.firstWhereOrNull((m) => m.metricType == MetricType.steps), isNull); + }); +} diff --git a/test/features/health/providers/health_sync_test.dart b/test/features/health/providers/health_sync_test.dart new file mode 100644 index 000000000..5f94eaf73 --- /dev/null +++ b/test/features/health/providers/health_sync_test.dart @@ -0,0 +1,189 @@ +/* + * 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_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_bridge/health.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:wger/core/shared_preferences.dart'; +import 'package:wger/features/health/models/health_reading.dart'; +import 'package:wger/features/health/providers/health_repository.dart'; +import 'package:wger/features/health/providers/health_sync.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/measurement_entry.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; + +import 'health_sync_test.mocks.dart'; + +@GenerateMocks([HealthRepository, MeasurementRepository]) +void main() { + group('HealthSyncState', () { + test('default state has sync disabled', () { + 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); + }); + }); + + group('syncOnAppOpen', () { + late MockHealthRepository health; + late MockMeasurementRepository measurements; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + await PreferenceHelper.instance.setHealthSyncEnabled(true); + + health = MockHealthRepository(); + measurements = MockMeasurementRepository(); + + when(health.ensureAuthorized(any)).thenAnswer((_) async => true); + when(health.sourceName).thenReturn('apple'); + when(measurements.addLocalDrift(any)).thenAnswer((_) async {}); + when(measurements.addLocalDriftCategory(any)).thenAnswer((_) async {}); + }); + + HealthSyncNotifier createNotifier() { + final container = ProviderContainer.test( + overrides: [ + healthRepositoryProvider.overrideWithValue(health), + measurementRepositoryProvider.overrideWithValue(measurements), + ], + ); + return container.read(healthSyncProvider.notifier); + } + + void stubReadings(List readings) { + when( + health.read( + types: anyNamed('types'), + start: anyNamed('start'), + end: anyNamed('end'), + ), + ).thenAnswer((_) async => readings); + } + + test('imports enabled metrics into new categories with converted values', () async { + when(measurements.getAllOnce()).thenAnswer((_) async => []); + stubReadings([ + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.2, + date: DateTime(2026, 1, 1), + externalId: 'bf-1', + ), + HealthReading( + type: HealthDataType.HEIGHT, + value: 1.8, + date: DateTime(2026, 1, 2), + externalId: 'h-1', + ), + ]); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 2); + + final createdCategories = verify( + measurements.addLocalDriftCategory(captureAny), + ).captured.cast(); + expect( + createdCategories.map((c) => c.metricType), + containsAll([MetricType.bodyFat, MetricType.height]), + ); + + final entries = verify( + measurements.addLocalDrift(captureAny), + ).captured.cast(); + + final bodyFat = entries.firstWhere((e) => e.externalId == 'bf-1'); + expect(bodyFat.value, closeTo(20, 0.001)); // fraction -> percent + expect(bodyFat.source, 'apple'); + + final height = entries.firstWhere((e) => e.externalId == 'h-1'); + expect(height.value, closeTo(180, 0.001)); // meters -> cm + }); + + test('skips readings already imported (dedup by externalId)', () async { + final existing = MeasurementCategory( + id: 'cat-bf', + name: 'Body fat', + unit: '%', + metricType: MetricType.bodyFat, + entries: [ + MeasurementEntry( + id: 'e1', + categoryId: 'cat-bf', + date: DateTime(2026, 1, 1), + value: 20, + notes: '', + externalId: 'bf-1', + ), + ], + ); + when(measurements.getAllOnce()).thenAnswer((_) async => [existing]); + stubReadings([ + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.2, + date: DateTime(2026, 1, 1), + externalId: 'bf-1', // already imported + ), + HealthReading( + type: HealthDataType.BODY_FAT_PERCENTAGE, + value: 0.22, + date: DateTime(2026, 1, 3), + externalId: 'bf-2', // new + ), + ]); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 1); + verifyNever(measurements.addLocalDriftCategory(any)); + + final entries = verify( + measurements.addLocalDrift(captureAny), + ).captured.cast(); + expect(entries.single.externalId, 'bf-2'); + }); + + test('does nothing when sync is disabled', () async { + await PreferenceHelper.instance.setHealthSyncEnabled(false); + + final count = await createNotifier().syncOnAppOpen(); + expect(count, 0); + verifyNever( + health.read( + types: anyNamed('types'), + start: anyNamed('start'), + end: anyNamed('end'), + ), + ); + }); + }); +} diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart new file mode 100644 index 000000000..952330127 --- /dev/null +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -0,0 +1,197 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/features/health/providers/health_sync_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:health_bridge/health.dart' as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i3; +import 'package:wger/features/health/models/health_reading.dart' as _i6; +import 'package:wger/features/health/providers/health_repository.dart' as _i2; +import 'package:wger/features/measurements/models/measurement_category.dart' as _i8; +import 'package:wger/features/measurements/models/measurement_entry.dart' as _i9; +import 'package:wger/features/measurements/providers/measurement_repository.dart' as _i7; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [HealthRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository { + MockHealthRepository() { + _i1.throwOnMissingStub(this); + } + + @override + String get sourceName => + (super.noSuchMethod( + Invocation.getter(#sourceName), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#sourceName), + ), + ) + as String); + + @override + _i4.Future isAvailable() => + (super.noSuchMethod( + Invocation.method(#isAvailable, []), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future ensureAuthorized(List<_i5.HealthDataType>? types) => + (super.noSuchMethod( + Invocation.method(#ensureAuthorized, [types]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future> read({ + required List<_i5.HealthDataType>? types, + required DateTime? start, + required DateTime? end, + }) => + (super.noSuchMethod( + Invocation.method(#read, [], { + #types: types, + #start: start, + #end: end, + }), + returnValue: _i4.Future>.value( + <_i6.HealthReading>[], + ), + ) + as _i4.Future>); +} + +/// A class which mocks [MeasurementRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementRepository extends _i1.Mock implements _i7.MeasurementRepository { + MockMeasurementRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Stream> watchAll() => + (super.noSuchMethod( + Invocation.method(#watchAll, []), + returnValue: _i4.Stream>.empty(), + ) + as _i4.Stream>); + + @override + _i4.Stream<_i8.MeasurementCategory?> watchLocalDriftCategoryById( + String? id, + ) => + (super.noSuchMethod( + Invocation.method(#watchLocalDriftCategoryById, [id]), + returnValue: _i4.Stream<_i8.MeasurementCategory?>.empty(), + ) + as _i4.Stream<_i8.MeasurementCategory?>); + + @override + _i4.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i4.Future>.value( + <_i8.MeasurementCategory>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future deleteLocalDrift(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDrift, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future updateLocalDrift(_i9.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#updateLocalDrift, [entry]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future addLocalDrift(_i9.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addLocalDrift, [entry]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future addLocalDriftGroupEntries( + List<_i9.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future deleteLocalDriftCategory(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDriftCategory, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future updateLocalDriftCategory( + _i8.MeasurementCategory? category, + ) => + (super.noSuchMethod( + Invocation.method(#updateLocalDriftCategory, [category]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future addLocalDriftCategory(_i8.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftCategory, [category]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/features/measurements/models/measurement_category_test.dart b/test/features/measurements/models/measurement_category_test.dart index e76820418..1f0619fe8 100644 --- a/test/features/measurements/models/measurement_category_test.dart +++ b/test/features/measurements/models/measurement_category_test.dart @@ -43,4 +43,34 @@ void main() { expect(() => category.findEntryById('abc'), throwsA(isA())); }); }); + + group('MetricType', () { + test('fromWire maps a known wire value to its enum case', () { + expect(MetricType.fromWire('body_fat'), MetricType.bodyFat); + expect(MetricType.fromWire('blood_pressure'), MetricType.bloodPressure); + expect(MetricType.fromWire('custom'), MetricType.custom); + }); + + test('fromWire defaults to custom for an unknown value', () { + expect(MetricType.fromWire('something_new'), MetricType.custom); + expect(MetricType.fromWire(''), MetricType.custom); + }); + + test('wireValue round-trips through fromWire for every case', () { + for (final type in MetricType.values) { + expect(MetricType.fromWire(type.wireValue), type); + } + }); + + test('isSummedPerDay is true only for cumulative daily metrics', () { + expect(MetricType.steps.isSummedPerDay, isTrue); + expect(MetricType.distance.isSummedPerDay, isTrue); + expect(MetricType.energy.isSummedPerDay, isTrue); + expect(MetricType.sleep.isSummedPerDay, isTrue); + + expect(MetricType.custom.isSummedPerDay, isFalse); + expect(MetricType.bodyWeight.isSummedPerDay, isFalse); + expect(MetricType.heartRate.isSummedPerDay, isFalse); + }); + }); } diff --git a/test/features/measurements/providers/measurement_notifier_test.dart b/test/features/measurements/providers/measurement_notifier_test.dart index f1987e09f..6e78a5c65 100644 --- a/test/features/measurements/providers/measurement_notifier_test.dart +++ b/test/features/measurements/providers/measurement_notifier_test.dart @@ -102,4 +102,63 @@ void main() { verify(mockRepo.watchAll()).called(1); }); }); + + group('setCategoryOrder', () { + // Three top-level categories: Body fat ('1'), Biceps ('2') and the blood + // pressure group parent ('bp'), whose children must stay untouched. + final categories = [ + ...getMeasurementCategories(), + ...getBloodPressureGroup(), + ]; + + setUp(() { + when(mockRepo.watchAll()).thenAnswer((_) => Stream.value(categories)); + when(mockRepo.reorderCategories(any)).thenAnswer((_) async {}); + }); + + // Loads the notifier with its state resolved from the watchAll stream. + Future loadedNotifier() async { + container.listen(measurementProvider, (_, _) {}); + await pumpEventQueue(); + return container.read(measurementProvider.notifier); + } + + test('moves an item down (newIndex already adjusted, onReorderItem semantics)', () async { + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 2); + verify(mockRepo.reorderCategories(['2', 'bp', '1'])).called(1); + }); + + test('moves an item up', () async { + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(2, 0); + verify(mockRepo.reorderCategories(['bp', '1', '2'])).called(1); + }); + + test('excludes children of multi-value groups', () async { + // Children interleaved between the top-level categories. + when(mockRepo.watchAll()).thenAnswer( + (_) => Stream.value([ + testMeasurementCategorySystolic, + ...getMeasurementCategories(), + testMeasurementCategoryDiastolic, + testMeasurementCategoryBloodPressure, + ]), + ); + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 1); + verify(mockRepo.reorderCategories(['2', '1', 'bp'])).called(1); + }); + + test('does nothing while the list is still loading', () async { + when(mockRepo.watchAll()).thenAnswer((_) => const Stream.empty()); + final notifier = await loadedNotifier(); + + await notifier.setCategoryOrder(0, 1); + verifyNever(mockRepo.reorderCategories(any)); + }); + }); } diff --git a/test/features/measurements/providers/measurement_notifier_test.mocks.dart b/test/features/measurements/providers/measurement_notifier_test.mocks.dart index 4f7399345..28cd63948 100644 --- a/test/features/measurements/providers/measurement_notifier_test.mocks.dart +++ b/test/features/measurements/providers/measurement_notifier_test.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( @@ -78,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( @@ -106,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } diff --git a/test/features/measurements/providers/measurement_repository_test.dart b/test/features/measurements/providers/measurement_repository_test.dart index 89a508cd5..9868aab38 100644 --- a/test/features/measurements/providers/measurement_repository_test.dart +++ b/test/features/measurements/providers/measurement_repository_test.dart @@ -222,4 +222,93 @@ void main() { expect(emitted.map((c) => c.id), ['c2']); }); }); + + group('multi-value groups', () { + Future seedBloodPressureGroup() async { + for (final category in getBloodPressureGroup()) { + await repo.addLocalDriftCategory(category); + } + } + + test('watchAll attaches children to their parent in group order', () async { + await seedBloodPressureGroup(); + + final emitted = await repo.watchAll().first; + + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.isGroup, isTrue); + expect(parent.children.map((c) => c.id), ['sys', 'dia']); + }); + + test('watchAll sorts categories by order before name', () async { + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c1', name: 'Aaa', unit: 'cm', order: 5), + ); + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c2', name: 'Zzz', unit: 'cm', order: 1), + ); + + final emitted = await repo.watchAll().first; + + expect(emitted.map((c) => c.id), ['c2', 'c1']); + }); + + test('addLocalDriftGroupEntries inserts one entry per component', () async { + await seedBloodPressureGroup(); + final date = DateTime.utc(2026, 7, 10, 14, 32); + + await repo.addLocalDriftGroupEntries([ + MeasurementEntry(id: 'e1', categoryId: 'sys', date: date, value: 120, notes: ''), + MeasurementEntry(id: 'e2', categoryId: 'dia', date: date, value: 80, notes: ''), + ]); + + final emitted = await repo.watchAll().first; + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.children.first.entries.single.value, 120); + expect(parent.children.last.entries.single.value, 80); + expect( + parent.children.first.entries.single.date, + parent.children.last.entries.single.date, + ); + }); + + test('deleteLocalDriftCategory removes children along with the parent', () async { + await seedBloodPressureGroup(); + + await repo.deleteLocalDriftCategory('bp'); + + final emitted = await repo.watchAll().first; + expect(emitted, isEmpty); + }); + }); + + group('reorderCategories', () { + test('persists the list positions as order', () async { + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c1', name: 'Aaa', unit: 'cm')); + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c2', name: 'Bbb', unit: 'cm')); + await repo.addLocalDriftCategory(MeasurementCategory(id: 'c3', name: 'Ccc', unit: 'cm')); + + await repo.reorderCategories(['c3', 'c1', 'c2']); + + final emitted = await repo.watchAll().first; + expect(emitted.map((c) => c.id), ['c3', 'c1', 'c2']); + expect(emitted.map((c) => c.order), [0, 1, 2]); + }); + + test('keeps the in-group order of children', () async { + for (final category in getBloodPressureGroup()) { + await repo.addLocalDriftCategory(category); + } + await repo.addLocalDriftCategory( + MeasurementCategory(id: 'c1', name: 'Waist', unit: 'cm', order: 1), + ); + + await repo.reorderCategories(['c1', 'bp']); + + final emitted = await repo.watchAll().first; + final parent = emitted.firstWhere((c) => c.id == 'bp'); + expect(parent.order, 1); + expect(parent.children.map((c) => c.id), ['sys', 'dia']); + }); + }); } diff --git a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart index 8b8ed109a..743054ede 100644 --- a/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_categories_screen_test.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( @@ -78,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( @@ -106,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } diff --git a/test/features/measurements/screens/measurement_category_sort_screen_test.dart b/test/features/measurements/screens/measurement_category_sort_screen_test.dart new file mode 100644 index 000000000..6b46b9e68 --- /dev/null +++ b/test/features/measurements/screens/measurement_category_sort_screen_test.dart @@ -0,0 +1,88 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:wger/features/measurements/providers/measurement_repository.dart'; +import 'package:wger/features/measurements/screens/measurement_category_sort_screen.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +import '../../../../test_data/measurements.dart'; +import 'measurement_category_sort_screen_test.mocks.dart'; + +@GenerateMocks([MeasurementRepository]) +void main() { + late MockMeasurementRepository mockRepo; + + // Three top-level categories: Body fat ('1'), Biceps ('2') and the blood + // pressure group parent ('bp'); its children must not be listed. + final categories = [ + ...getMeasurementCategories(), + ...getBloodPressureGroup(), + ]; + + setUp(() { + mockRepo = MockMeasurementRepository(); + when(mockRepo.watchAll()).thenAnswer((_) => Stream.value(categories)); + when(mockRepo.reorderCategories(any)).thenAnswer((_) async {}); + }); + + Widget createSortScreen() { + return ProviderScope( + overrides: [ + measurementRepositoryProvider.overrideWithValue(mockRepo), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MeasurementCategorySortScreen(), + ), + ); + } + + testWidgets('lists only top-level categories', (tester) async { + await tester.pumpWidget(createSortScreen()); + await tester.pumpAndSettle(); + + expect(find.text('Body fat'), findsOneWidget); + expect(find.text('Biceps'), findsOneWidget); + expect(find.text('Blood pressure'), findsOneWidget); + expect(find.text('Systolic'), findsNothing); + expect(find.text('Diastolic'), findsNothing); + expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); + }); + + testWidgets('dragging an item to the bottom persists the new order', (tester) async { + await tester.pumpWidget(createSortScreen()); + await tester.pumpAndSettle(); + + final firstHandle = find.byIcon(Icons.drag_handle).first; + final itemHeight = tester.getSize(find.byType(ListTile).first).height; + await tester.timedDrag( + firstHandle, + Offset(0, itemHeight * 3), + const Duration(milliseconds: 300), + ); + await tester.pumpAndSettle(); + + verify(mockRepo.reorderCategories(['2', 'bp', '1'])).called(1); + }); +} diff --git a/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart b/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart new file mode 100644 index 000000000..fa002a9c4 --- /dev/null +++ b/test/features/measurements/screens/measurement_category_sort_screen_test.mocks.dart @@ -0,0 +1,139 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/features/measurements/screens/measurement_category_sort_screen_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/features/measurements/models/measurement_category.dart' as _i4; +import 'package:wger/features/measurements/models/measurement_entry.dart' as _i5; +import 'package:wger/features/measurements/providers/measurement_repository.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [MeasurementRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepository { + MockMeasurementRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Stream> watchAll() => + (super.noSuchMethod( + Invocation.method(#watchAll, []), + returnValue: _i3.Stream>.empty(), + ) + as _i3.Stream>); + + @override + _i3.Stream<_i4.MeasurementCategory?> watchLocalDriftCategoryById( + String? id, + ) => + (super.noSuchMethod( + Invocation.method(#watchLocalDriftCategoryById, [id]), + returnValue: _i3.Stream<_i4.MeasurementCategory?>.empty(), + ) + as _i3.Stream<_i4.MeasurementCategory?>); + + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + + @override + _i3.Future deleteLocalDrift(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDrift, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future updateLocalDrift(_i5.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#updateLocalDrift, [entry]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDrift(_i5.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addLocalDrift, [entry]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future deleteLocalDriftCategory(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteLocalDriftCategory, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future updateLocalDriftCategory( + _i4.MeasurementCategory? category, + ) => + (super.noSuchMethod( + Invocation.method(#updateLocalDriftCategory, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future addLocalDriftCategory(_i4.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftCategory, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} diff --git a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart index 8612dcbf4..5ca65e223 100644 --- a/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart +++ b/test/features/measurements/screens/measurement_entries_screen_test.mocks.dart @@ -60,6 +60,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( @@ -87,6 +97,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( @@ -115,6 +136,15 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } /// A class which mocks [NutritionRepository]. diff --git a/test/features/measurements/widgets/charts_test.dart b/test/features/measurements/widgets/charts_test.dart new file mode 100644 index 000000000..0043acfc3 --- /dev/null +++ b/test/features/measurements/widgets/charts_test.dart @@ -0,0 +1,105 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/features/measurements/widgets/charts.dart'; + +void main() { + MeasurementChartEntry entry(num value, DateTime date) => MeasurementChartEntry(value, date); + + group('aggregatePerDay', () { + test('returns an empty list for no entries', () { + expect(aggregatePerDay([]), isEmpty); + }); + + test('sums entries sharing a calendar day into one point', () { + final result = aggregatePerDay([ + entry(1000, DateTime(2026, 1, 1, 8)), + entry(2500, DateTime(2026, 1, 1, 20)), + ]); + + expect(result.length, 1); + expect(result.single.value, 3500); + // time-of-day is stripped to the day boundary + expect(result.single.date, DateTime(2026, 1, 1)); + }); + + test('keeps separate days apart and sorts them ascending', () { + final result = aggregatePerDay([ + entry(30, DateTime(2026, 1, 3)), + entry(10, DateTime(2026, 1, 1, 6)), + entry(5, DateTime(2026, 1, 1, 23)), + entry(20, DateTime(2026, 1, 2)), + ]); + + expect(result.map((e) => e.date), [ + DateTime(2026, 1, 1), + DateTime(2026, 1, 2), + DateTime(2026, 1, 3), + ]); + expect(result.map((e) => e.value), [15, 20, 30]); + }); + }); + + group('smoothedTrendline', () { + test('returns an empty list for no entries', () { + expect(smoothedTrendline([]), isEmpty); + }); + + test('seeds the first point with the raw value and keeps the dates', () { + final input = [ + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + entry(30, DateTime(2026, 1, 3)), + ]; + final result = smoothedTrendline(input, period: 10); + + expect(result.length, 3); + expect(result.first.value, 10); // seed == first raw value + expect(result.map((e) => e.date), input.map((e) => e.date)); + }); + + test('EMA follows a rising series but lags behind the raw values', () { + final result = smoothedTrendline([ + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + entry(30, DateTime(2026, 1, 3)), + ], period: 10); + + // smoothing k = 2 / (10 + 1) ~= 0.1818 + // point 2: 20*k + 10*(1-k) = 11.818... + expect(result[1].value, closeTo(11.818, 0.001)); + // point 3: 30*k + 11.818*(1-k) = 15.123... + expect(result[2].value, closeTo(15.123, 0.001)); + // trend trails the raw climb + expect(result[2].value, lessThan(30)); + }); + + test('sorts unordered input by date before smoothing', () { + final result = smoothedTrendline([ + entry(30, DateTime(2026, 1, 3)), + entry(10, DateTime(2026, 1, 1)), + entry(20, DateTime(2026, 1, 2)), + ], period: 10); + + expect(result.first.date, DateTime(2026, 1, 1)); + expect(result.first.value, 10); + expect(result.last.date, DateTime(2026, 1, 3)); + }); + }); +} diff --git a/test/powersync/connector_test.dart b/test/powersync/connector_test.dart index abdcf059b..556bbf9b1 100644 --- a/test/powersync/connector_test.dart +++ b/test/powersync/connector_test.dart @@ -73,6 +73,16 @@ void main() { expect(out.containsKey('routine_id'), isFalse); }); + test('keeps `external_id`, which is a value column and not a foreign key', () { + final out = connector.genericTransform( + 'measurements_measurement', + {'external_id': 'abc-123', 'category_id': 5}, + '1', + ); + expect(out['external_id'], 'abc-123'); + expect(out['category'], 5); + }); + test('handles null opData (delete events)', () { final out = connector.genericTransform('manager_routine', null, '99'); expect(out, {'id': '99'}); diff --git a/test/screenshots/screenshots_01_dashboard.mocks.dart b/test/screenshots/screenshots_01_dashboard.mocks.dart index 82a19b2ff..ebe4614fd 100644 --- a/test/screenshots/screenshots_01_dashboard.mocks.dart +++ b/test/screenshots/screenshots_01_dashboard.mocks.dart @@ -472,6 +472,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo ) as _i10.Stream<_i24.MeasurementCategory?>); + @override + _i10.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i10.Future>.value( + <_i24.MeasurementCategory>[], + ), + ) + as _i10.Future>); + @override _i10.Future deleteLocalDrift(String? id) => (super.noSuchMethod( @@ -499,6 +509,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo ) as _i10.Future); + @override + _i10.Future addLocalDriftGroupEntries( + List<_i25.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + @override _i10.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( @@ -527,6 +548,15 @@ class MockMeasurementRepository extends _i1.Mock implements _i23.MeasurementRepo returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + + @override + _i10.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); } /// A class which mocks [UserProfileRepository]. diff --git a/test/screenshots/screenshots_04_measurements.mocks.dart b/test/screenshots/screenshots_04_measurements.mocks.dart index ba5ffa86f..7272e698c 100644 --- a/test/screenshots/screenshots_04_measurements.mocks.dart +++ b/test/screenshots/screenshots_04_measurements.mocks.dart @@ -51,6 +51,16 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Stream<_i4.MeasurementCategory?>); + @override + _i3.Future> getAllOnce() => + (super.noSuchMethod( + Invocation.method(#getAllOnce, []), + returnValue: _i3.Future>.value( + <_i4.MeasurementCategory>[], + ), + ) + as _i3.Future>); + @override _i3.Future deleteLocalDrift(String? id) => (super.noSuchMethod( @@ -78,6 +88,17 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos ) as _i3.Future); + @override + _i3.Future addLocalDriftGroupEntries( + List<_i5.MeasurementEntry>? entries, + ) => + (super.noSuchMethod( + Invocation.method(#addLocalDriftGroupEntries, [entries]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + @override _i3.Future deleteLocalDriftCategory(String? id) => (super.noSuchMethod( @@ -106,4 +127,13 @@ class MockMeasurementRepository extends _i1.Mock implements _i2.MeasurementRepos returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future reorderCategories(List? orderedIds) => + (super.noSuchMethod( + Invocation.method(#reorderCategories, [orderedIds]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); } diff --git a/test_data/measurements.dart b/test_data/measurements.dart index 3ada4ceea..73c237751 100644 --- a/test_data/measurements.dart +++ b/test_data/measurements.dart @@ -78,6 +78,40 @@ final testMeasurementEntry8 = MeasurementEntry( notes: '', ); +/// Multi-value group: blood pressure with systolic/diastolic components. +final testMeasurementCategoryBloodPressure = MeasurementCategory( + id: 'bp', + name: 'Blood pressure', + unit: 'mmHg', + metricType: MetricType.bloodPressure, +); + +final testMeasurementCategorySystolic = MeasurementCategory( + id: 'sys', + name: 'Systolic', + unit: 'mmHg', + parentId: 'bp', + order: 0, +); + +final testMeasurementCategoryDiastolic = MeasurementCategory( + id: 'dia', + name: 'Diastolic', + unit: 'mmHg', + parentId: 'bp', + order: 1, +); + +/// The blood pressure group as a flat list (parent first), as stored in the +/// database. Not included in [getMeasurementCategories]. +List getBloodPressureGroup() { + return [ + testMeasurementCategoryBloodPressure, + testMeasurementCategorySystolic, + testMeasurementCategoryDiastolic, + ]; +} + List getMeasurementCategories() { return [ MeasurementCategory(