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