Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions lib/features/health/providers/health_entry_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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 = <String, dynamic>{
Expand Down
2 changes: 1 addition & 1 deletion lib/features/health/providers/health_importer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions lib/features/health/providers/health_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double?> 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<double>(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];
Expand Down
67 changes: 67 additions & 0 deletions test/features/health/providers/health_importer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ void main() {
(invocation) async => (invocation.positionalArguments.first as List<HealthDataType>).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 {});
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/features/health/providers/health_importer_test.mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +100,18 @@ class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository {
)
as _i4.Future<bool>);

@override
_i4.Future<double?> platformTotal(
_i5.HealthDataType? type,
DateTime? start,
DateTime? end,
) =>
(super.noSuchMethod(
Invocation.method(#platformTotal, [type, start, end]),
returnValue: _i4.Future<double?>.value(),
)
as _i4.Future<double?>);

@override
_i4.Future<void> read({
required List<_i5.HealthDataType>? types,
Expand Down
13 changes: 13 additions & 0 deletions test/features/health/providers/health_sync_test.mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +100,18 @@ class MockHealthRepository extends _i1.Mock implements _i2.HealthRepository {
)
as _i4.Future<bool>);

@override
_i4.Future<double?> platformTotal(
_i5.HealthDataType? type,
DateTime? start,
DateTime? end,
) =>
(super.noSuchMethod(
Invocation.method(#platformTotal, [type, start, end]),
returnValue: _i4.Future<double?>.value(),
)
as _i4.Future<double?>);

@override
_i4.Future<void> read({
required List<_i5.HealthDataType>? types,
Expand Down
Loading