Skip to content

Commit 01c13b1

Browse files
committed
Add health repository
This follows the convention in the rest of the application and allows us to easily mock it for tests.
1 parent 18b745f commit 01c13b1

6 files changed

Lines changed: 518 additions & 81 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (c) 2026 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import 'package:health/health.dart';
20+
21+
/// A single reading pulled from a health platform, reduced to the fields the
22+
/// importer needs. Keeps the `health` package's [HealthDataPoint] out of the
23+
/// notifier so the sync logic can be tested with plain values.
24+
class HealthReading {
25+
const HealthReading({
26+
required this.type,
27+
required this.value,
28+
required this.date,
29+
this.externalId,
30+
});
31+
32+
/// The platform type this reading belongs to (matched against
33+
/// `HealthMetric.dataType`).
34+
final HealthDataType type;
35+
36+
/// Numeric value in the platform's native unit.
37+
final double value;
38+
39+
/// Start of the reading.
40+
final DateTime date;
41+
42+
/// Platform record UUID for deduplication; `null` when the platform gives none.
43+
final String? externalId;
44+
45+
/// Builds a reading from a platform data point, or `null` for a non-numeric
46+
/// point (which the importer cannot store).
47+
static HealthReading? fromDataPoint(HealthDataPoint point) {
48+
final value = point.value;
49+
if (value is! NumericHealthValue) {
50+
return null;
51+
}
52+
return HealthReading(
53+
type: point.type,
54+
value: value.numericValue.toDouble(),
55+
date: point.dateFrom,
56+
externalId: point.uuid.isEmpty ? null : point.uuid,
57+
);
58+
}
59+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (c) 2026 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import 'dart:io';
20+
21+
import 'package:flutter_riverpod/flutter_riverpod.dart';
22+
import 'package:health/health.dart';
23+
import 'package:logging/logging.dart';
24+
import 'package:wger/features/health/models/health_reading.dart';
25+
26+
final healthRepositoryProvider = Provider<HealthRepository>((ref) {
27+
return HealthRepository();
28+
});
29+
30+
/// Wraps the `health` plugin so the rest of the app talks to a small, mockable
31+
/// surface instead of Apple Health / Health Connect directly.
32+
class HealthRepository {
33+
HealthRepository([Health? health]) : _health = health ?? Health();
34+
35+
final Health _health;
36+
final _logger = Logger('HealthRepository');
37+
38+
/// Identifies which platform a reading came from.
39+
String get sourceName => Platform.isIOS ? 'apple_health' : 'health_connect';
40+
41+
/// Whether a health platform is usable on this device.
42+
Future<bool> isAvailable() async {
43+
if (Platform.isAndroid) {
44+
await _health.configure();
45+
final status = await _health.getHealthConnectSdkStatus();
46+
return status == HealthConnectSdkStatus.sdkAvailable;
47+
}
48+
return Platform.isIOS;
49+
}
50+
51+
/// Ensures READ access to [types] (requesting it if needed) and, on Android,
52+
/// access to historical data. Returns whether access is granted.
53+
Future<bool> ensureAuthorized(List<HealthDataType> types) async {
54+
await _health.configure();
55+
final access = List.filled(types.length, HealthDataAccess.READ);
56+
57+
final hasPerms = await _health.hasPermissions(types, permissions: access);
58+
if (hasPerms != true) {
59+
final granted = await _health.requestAuthorization(types, permissions: access);
60+
if (!granted) {
61+
_logger.warning('Health permissions not granted');
62+
return false;
63+
}
64+
}
65+
66+
if (Platform.isAndroid) {
67+
await _health.requestHealthDataHistoryAuthorization();
68+
}
69+
return true;
70+
}
71+
72+
/// Reads all [types] between [start] and [end], platform-deduplicated and
73+
/// reduced to numeric [HealthReading]s.
74+
Future<List<HealthReading>> read({
75+
required List<HealthDataType> types,
76+
required DateTime start,
77+
required DateTime end,
78+
}) async {
79+
final points = _health.removeDuplicates(
80+
await _health.getHealthDataFromTypes(types: types, startTime: start, endTime: end),
81+
);
82+
return points.map(HealthReading.fromDataPoint).whereType<HealthReading>().toList();
83+
}
84+
}

lib/features/health/providers/health_sync.dart

Lines changed: 33 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@
1616
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
*/
1818

19-
import 'dart:io';
20-
2119
import 'package:collection/collection.dart';
2220
import 'package:health/health.dart';
2321
import 'package:logging/logging.dart';
2422
import 'package:powersync/powersync.dart' as ps;
2523
import 'package:riverpod_annotation/riverpod_annotation.dart';
2624
import 'package:wger/core/shared_preferences.dart';
2725
import 'package:wger/features/health/models/health_metric.dart';
26+
import 'package:wger/features/health/providers/health_repository.dart';
2827
import 'package:wger/features/measurements/models/measurement_category.dart';
2928
import 'package:wger/features/measurements/models/measurement_entry.dart';
3029
import 'package:wger/features/measurements/providers/measurement_repository.dart';
@@ -52,25 +51,22 @@ class HealthSyncState {
5251
}
5352

5453
/// Imports body metrics from Apple Health / Health Connect into measurement
55-
/// categories. Read-only: reads the platform, writes to the local Drift DB, and
56-
/// lets PowerSync push the rows up. Re-imports are deduplicated via each
57-
/// measurement's [MeasurementEntry.externalId] (the platform record UUID).
54+
/// categories. Read-only: reads the platform (via [HealthRepository]), writes to
55+
/// the local Drift DB, and lets PowerSync push the rows up. Re-imports are
56+
/// deduplicated via each measurement's [MeasurementEntry.externalId] (the
57+
/// platform record UUID).
5858
@Riverpod(keepAlive: true)
5959
class HealthSyncNotifier extends _$HealthSyncNotifier {
6060
final _logger = Logger('HealthSyncNotifier');
61-
late final Health _health;
62-
late final MeasurementRepository _repo;
61+
late final HealthRepository _health;
62+
late final MeasurementRepository _measurements;
6363

6464
List<HealthDataType> get _types => enabledHealthMetrics.map((m) => m.dataType).toList();
6565

66-
List<HealthDataAccess> get _readAccess => List.filled(_types.length, HealthDataAccess.READ);
67-
68-
String get _sourceName => Platform.isIOS ? 'apple_health' : 'health_connect';
69-
7066
@override
7167
HealthSyncState build() {
72-
_health = Health();
73-
_repo = ref.read(measurementRepositoryProvider);
68+
_health = ref.read(healthRepositoryProvider);
69+
_measurements = ref.read(measurementRepositoryProvider);
7470
_loadPersistedState();
7571
return const HealthSyncState();
7672
}
@@ -82,33 +78,16 @@ class HealthSyncNotifier extends _$HealthSyncNotifier {
8278
}
8379

8480
/// Whether a health platform is available on this device.
85-
Future<bool> isAvailable() async {
86-
if (Platform.isAndroid) {
87-
await _health.configure();
88-
final status = await _health.getHealthConnectSdkStatus();
89-
return status == HealthConnectSdkStatus.sdkAvailable;
90-
}
91-
return Platform.isIOS;
92-
}
81+
Future<bool> isAvailable() => _health.isAvailable();
9382

9483
/// Requests permissions, persists the preference, and runs an initial import.
9584
Future<int> enableSync() async {
9685
_logger.info('Enabling health sync');
97-
await _health.configure();
98-
99-
final authorized = await _health.requestAuthorization(_types, permissions: _readAccess);
100-
if (!authorized) {
101-
_logger.warning('Health permissions not granted');
86+
if (!await _health.ensureAuthorized(_types)) {
10287
return 0;
10388
}
104-
105-
if (Platform.isAndroid) {
106-
await _health.requestHealthDataHistoryAuthorization();
107-
}
108-
10989
await PreferenceHelper.instance.setHealthSyncEnabled(true);
11090
state = state.copyWith(isEnabled: true);
111-
11291
return syncOnAppOpen();
11392
}
11493

@@ -133,42 +112,32 @@ class HealthSyncNotifier extends _$HealthSyncNotifier {
133112
state = state.copyWith(isEnabled: true, isSyncing: true);
134113

135114
try {
136-
await _health.configure();
137-
138-
final hasPerms = await _health.hasPermissions(_types, permissions: _readAccess);
139-
if (hasPerms != true) {
140-
final authorized = await _health.requestAuthorization(_types, permissions: _readAccess);
141-
if (!authorized) {
142-
_logger.warning('Health permissions not granted during sync');
143-
state = state.copyWith(isSyncing: false);
144-
return 0;
145-
}
115+
if (!await _health.ensureAuthorized(_types)) {
116+
_logger.warning('Health permissions not granted during sync');
117+
state = state.copyWith(isSyncing: false);
118+
return 0;
146119
}
147120

148121
final lastSyncStr = await prefs.getLastHealthSyncTimestamp();
149122
final startTime = lastSyncStr != null ? DateTime.parse(lastSyncStr) : DateTime(2000);
150123
final endTime = DateTime.now();
151124
_logger.info('Syncing health data from $startTime to $endTime');
152125

153-
var points = await _health.getHealthDataFromTypes(
154-
types: _types,
155-
startTime: startTime,
156-
endTime: endTime,
157-
);
158-
points = _health.removeDuplicates(points);
159-
if (points.isEmpty) {
126+
final readings = await _health.read(types: _types, start: startTime, end: endTime);
127+
if (readings.isEmpty) {
160128
_logger.info('No new health data');
161129
state = state.copyWith(isSyncing: false, lastSyncCount: 0);
162130
return 0;
163131
}
164132

165-
final categories = await _repo.getAllOnce();
133+
final categories = await _measurements.getAllOnce();
134+
final source = _health.sourceName;
166135
var synced = 0;
167136
DateTime? latest;
168137

169138
for (final metric in enabledHealthMetrics) {
170-
final metricPoints = points.where((p) => p.type == metric.dataType);
171-
if (metricPoints.isEmpty) {
139+
final metricReadings = readings.where((r) => r.type == metric.dataType);
140+
if (metricReadings.isEmpty) {
172141
continue;
173142
}
174143

@@ -178,33 +147,29 @@ class HealthSyncNotifier extends _$HealthSyncNotifier {
178147
if (e.externalId != null) e.externalId!,
179148
};
180149

181-
for (final point in metricPoints) {
182-
final value = point.value;
183-
if (value is! NumericHealthValue) {
184-
continue;
185-
}
186-
final uuid = point.uuid;
187-
if (uuid.isNotEmpty && seen.contains(uuid)) {
150+
for (final reading in metricReadings) {
151+
final uuid = reading.externalId;
152+
if (uuid != null && seen.contains(uuid)) {
188153
continue;
189154
}
190155

191-
await _repo.addLocalDrift(
156+
await _measurements.addLocalDrift(
192157
MeasurementEntry(
193158
categoryId: category.id!,
194-
date: point.dateFrom,
195-
value: metric.toCategoryValue(value.numericValue.toDouble()),
159+
date: reading.date,
160+
value: metric.toCategoryValue(reading.value),
196161
notes: '',
197-
source: _sourceName,
198-
externalId: uuid.isEmpty ? null : uuid,
162+
source: source,
163+
externalId: uuid,
199164
),
200165
);
201166

202-
if (uuid.isNotEmpty) {
167+
if (uuid != null) {
203168
seen.add(uuid);
204169
}
205170
synced++;
206-
if (latest == null || point.dateFrom.isAfter(latest)) {
207-
latest = point.dateFrom;
171+
if (latest == null || reading.date.isAfter(latest)) {
172+
latest = reading.date;
208173
}
209174
}
210175
}
@@ -243,7 +208,7 @@ class HealthSyncNotifier extends _$HealthSyncNotifier {
243208
unit: metric.unit,
244209
metricType: metric.metricType,
245210
);
246-
await _repo.addLocalDriftCategory(category);
211+
await _measurements.addLocalDriftCategory(category);
247212
categories.add(category);
248213
return category;
249214
}

lib/features/health/providers/health_sync.g.dart

Lines changed: 17 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)