From c46a6a5554b7fe96cd5f1133af028bd94fbfa713 Mon Sep 17 00:00:00 2001 From: Mike Smith <194665430+strobeskinzzz@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:26:05 +1000 Subject: [PATCH] Use Health Connect's daily totals for steps, distance and active energy --- .../health/providers/health_entry_writer.dart | 18 ++++- .../health/providers/health_importer.dart | 2 +- .../health/providers/health_repository.dart | 38 +++++++++++ .../providers/health_importer_test.dart | 67 +++++++++++++++++++ .../providers/health_importer_test.mocks.dart | 13 ++++ .../providers/health_sync_test.mocks.dart | 13 ++++ 6 files changed, 148 insertions(+), 3 deletions(-) diff --git a/lib/features/health/providers/health_entry_writer.dart b/lib/features/health/providers/health_entry_writer.dart index 335322c7b..d0b69784e 100644 --- a/lib/features/health/providers/health_entry_writer.dart +++ b/lib/features/health/providers/health_entry_writer.dart @@ -24,6 +24,7 @@ import 'package:wger/features/health/models/daily_windows.dart'; import 'package:wger/features/health/models/health_metric.dart'; import 'package:wger/features/health/models/health_reading.dart'; import 'package:wger/features/health/providers/health_importer.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/models/unit_conversion.dart'; @@ -38,10 +39,13 @@ final _uuidPattern = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[ /// two counters a run reports at the end. Created per run, since both are /// about that run. class HealthEntryWriter { - HealthEntryWriter(this._measurements, this._source); + HealthEntryWriter(this._measurements, this._source, this._health); final MeasurementRepository _measurements; + /// Used for the platform's own daily totals, see [_writeDailyAggregate] + final HealthRepository _health; + /// The measurement `source` of this platform (`apple`, `google`) final String _source; @@ -167,11 +171,21 @@ class HealthEntryWriter { ) async { final category = target.category; final values = samples.map((r) => metric.toCategoryValue(r.value)).toList(); - final aggregate = switch (metric.dailyAggregation!) { + var aggregate = switch (metric.dailyAggregation!) { DailyAggregation.average => values.average, DailyAggregation.sum => values.sum, DailyAggregation.mergedDuration => mergedDurationMinutes(samples), }; + // Cumulative metrics: take the platform's deduplicated day total instead + // of the raw sum, which double counts overlapping records. Falls back to + // the raw sum when the platform total is unavailable. + final type = metric.dataType; + if (metric.dailyAggregation == DailyAggregation.sum && type != null) { + final total = await _health.platformTotal(type, day, dayEnd(day, metric)); + if (total != null) { + aggregate = metric.toCategoryValue(total); + } + } // Round like the raw import: the server stores Decimal with 2 places final value = (aggregate * 100).roundToDouble() / 100; final extraData = { diff --git a/lib/features/health/providers/health_importer.dart b/lib/features/health/providers/health_importer.dart index cc4aa10f5..e689f35c6 100644 --- a/lib/features/health/providers/health_importer.dart +++ b/lib/features/health/providers/health_importer.dart @@ -159,7 +159,7 @@ class HealthImporter { return (imported: 0, issue: HealthSyncIssue.failed, completed: false); } - _writer = HealthEntryWriter(_measurements, _health.sourceName); + _writer = HealthEntryWriter(_measurements, _health.sourceName, _health); final run = await _prepare(metrics, readable, userId); _reportProgress(run); for (final metric in metrics) { diff --git a/lib/features/health/providers/health_repository.dart b/lib/features/health/providers/health_repository.dart index 6d498f68b..963ba2597 100644 --- a/lib/features/health/providers/health_repository.dart +++ b/lib/features/health/providers/health_repository.dart @@ -189,6 +189,44 @@ class HealthRepository { return true; } + /// The total of a cumulative [type] (steps, distance, active energy) + /// between [start] and [end] as Health Connect itself reports it. + /// + /// Health Connect resolves overlapping records from several apps by the + /// user's data source priority, and drops an app's second copy of the same + /// interval (e.g. a watch writing both its interval buckets and a workout + /// record). Adding up the raw records instead counts those twice. The value + /// is in the platform's raw unit (count, meters, kcal), like a raw read. + /// + /// Null on iOS, on failure, or when the platform returned nothing, so the + /// caller can fall back to the raw sum. + Future platformTotal(HealthDataType type, DateTime start, DateTime end) async { + if (kIsWeb || !Platform.isAndroid || !end.isAfter(start)) { + return null; + } + try { + final points = await _health.getHealthIntervalDataFromTypes( + startDate: start, + endDate: end, + types: [type], + // One bucket covering the whole range + interval: end.difference(start).inSeconds, + ); + final values = [ + for (final point in points) + if (point.value is NumericHealthValue) + (point.value as NumericHealthValue).numericValue.toDouble(), + ]; + if (values.isEmpty) { + return null; + } + return values.fold(0, (a, b) => a + b); + } catch (e) { + _logger.warning('Aggregate read of ${type.name} failed, using the raw sum: $e'); + return null; + } + } + /// Reads all [types] between [start] and [end] in windows of [window], /// handing each window's readings to [onBatch] as soon as it returned: /// the peak memory stays at one window (see [HealthMetric.readWindow]; diff --git a/test/features/health/providers/health_importer_test.dart b/test/features/health/providers/health_importer_test.dart index c1ef5455e..0790e7d0b 100644 --- a/test/features/health/providers/health_importer_test.dart +++ b/test/features/health/providers/health_importer_test.dart @@ -95,6 +95,8 @@ void main() { (invocation) async => (invocation.positionalArguments.first as List).toSet(), ); when(health.sourceName).thenReturn('apple'); + // No platform total by default: summed metrics fall back to their raw sum + when(health.platformTotal(any, any, any)).thenAnswer((_) async => null); when(measurements.addLocalDrift(any)).thenAnswer((_) async {}); when(measurements.updateLocalDrift(any)).thenAnswer((_) async {}); when(measurements.addLocalDriftCategory(any)).thenAnswer((_) async {}); @@ -856,6 +858,71 @@ void main() { ); }); + test('takes a summed day total from the platform, not the raw sum', () async { + // A watch writes a workout's steps twice, once in its interval buckets + // and once as a record of its own. Health Connect counts the overlap + // once; adding up the raw records counts it twice. + when(health.platformTotal(HealthDataType.STEPS, any, any)).thenAnswer((_) async => 7873); + stubReadings([ + HealthReading( + type: HealthDataType.STEPS, + value: 3866, + date: DateTime(2026, 10, 4, 11), + dateTo: DateTime(2026, 10, 4, 11, 30), + externalId: _idS1, + ), + HealthReading( + type: HealthDataType.STEPS, + value: 4007, + date: DateTime(2026, 10, 4, 11, 30), + dateTo: DateTime(2026, 10, 4, 12), + externalId: _idS2, + ), + HealthReading( + type: HealthDataType.STEPS, + value: 7813, + date: DateTime(2026, 10, 4, 11, 8), + dateTo: DateTime(2026, 10, 4, 11, 55), + externalId: _idS3, + ), + ]); + + await runImport(); + + final entry = + verify(measurements.addLocalDrift(captureAny)).captured.single as MeasurementEntry; + expect(entry.date, DateTime(2026, 10, 4)); + expect(entry.value, 7873); // not 15686 + verify( + health.platformTotal(HealthDataType.STEPS, DateTime(2026, 10, 4), DateTime(2026, 10, 5)), + ).called(1); + }); + + test('falls back to the raw sum when the platform has no total', () async { + stubReadings([ + HealthReading( + type: HealthDataType.STEPS, + value: 3866, + date: DateTime(2026, 10, 4, 11), + dateTo: DateTime(2026, 10, 4, 11, 30), + externalId: _idS1, + ), + HealthReading( + type: HealthDataType.STEPS, + value: 133, + date: DateTime(2026, 10, 4, 10, 30), + dateTo: DateTime(2026, 10, 4, 11), + externalId: _idS2, + ), + ]); + + await runImport(); + + final entry = + verify(measurements.addLocalDrift(captureAny)).captured.single as MeasurementEntry; + expect(entry.value, 3999); + }); + test('aggregates a day split across two read windows once', () async { // The batches arrive per window, but a day whose samples straddle a // window boundary must still become a single aggregate of all of them diff --git a/test/features/health/providers/health_importer_test.mocks.dart b/test/features/health/providers/health_importer_test.mocks.dart index 42f23a149..f80ef3723 100644 --- a/test/features/health/providers/health_importer_test.mocks.dart +++ b/test/features/health/providers/health_importer_test.mocks.dart @@ -3,6 +3,7 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'dart:async' as _i4; import 'package:health_bridge/health.dart' as _i5; @@ -99,6 +100,18 @@ class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository { ) as _i4.Future); + @override + _i4.Future platformTotal( + _i5.HealthDataType? type, + DateTime? start, + DateTime? end, + ) => + (super.noSuchMethod( + Invocation.method(#platformTotal, [type, start, end]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + @override _i4.Future read({ required List<_i5.HealthDataType>? types, diff --git a/test/features/health/providers/health_sync_test.mocks.dart b/test/features/health/providers/health_sync_test.mocks.dart index 2af31fb18..d43bca2cf 100644 --- a/test/features/health/providers/health_sync_test.mocks.dart +++ b/test/features/health/providers/health_sync_test.mocks.dart @@ -3,6 +3,7 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'dart:async' as _i4; import 'package:health_bridge/health.dart' as _i5; @@ -99,6 +100,18 @@ class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository { ) as _i4.Future); + @override + _i4.Future platformTotal( + _i5.HealthDataType? type, + DateTime? start, + DateTime? end, + ) => + (super.noSuchMethod( + Invocation.method(#platformTotal, [type, start, end]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + @override _i4.Future read({ required List<_i5.HealthDataType>? types,