feat: opt-in workout import from Health Connect / Apple Health - #651
feat: opt-in workout import from Health Connect / Apple Health#651EugeneSusla wants to merge 32 commits into
Conversation
Adds two nullable fields to UserActivityDBO and its entity: the platform record id an activity was imported from, and the energy the exporting device reported before any calorie-credit multiplier was applied. Both are null for every manually logged activity, so existing rows read exactly as they did before. The external id doubles as the dedupe key for the workout importer, so the update path carries it over rather than dropping it — losing it would make an edited imported workout eligible for a second import — and the data source gains a lookup for the ids already filed since a given date.
Three new nullable config fields: whether workout import is enabled, the share of a device-reported workout's energy that counts toward the daily goal, and the watermark marking how far the last import read. All three default to off / unset, so a config written before the feature existed reads as opted out and no health data is touched until the user says so. A stored multiplier outside the supported 0.50-1.00 range is treated as corrupt and dropped rather than silently scaling every future import by a nonsense factor.
Logging an activity is never just a box insert: burned calories raise the day's goal ceiling 1:1 and the macro goals along with it, and a day that has never been tracked has to be created first or there is nothing to raise. The Quick Add sheet had that sequence copied out of ActivityDetailBloc; a second programmatic writer would have copied it again. LogUserActivityUsecase now owns it. The day is passed separately from the activity because an activity's own timestamp can fall on the other side of a configured day boundary, so the caller decides which diary column it belongs to. Quick Add's behaviour is unchanged.
…thKit Adds the `health` plugin and puts the app's entire surface onto the platform health store behind one abstraction, so nothing above the data source layer ever loads a method channel — the plugin cannot run under `flutter test`, and this is what keeps the import pipeline testable. Two implementations: a thin wrapper over the plugin that asks for read-only access to workouts and body fat percentage and hands back plugin-free records, and a no-op that reports itself unavailable. The locator probes once at startup and registers whichever works; a plugin that fails to initialise is logged and falls back rather than taking the app down with it. The dependency is pinned below health 13.3.2: that release pulls device_info_plus 13.2, whose win32 ^6 constraint collides with package_info_plus 9's win32 ^5.
A watch reporting "480 kcal burned" is measuring the workout, not the day: bodies claw a good part of that back over the following hours, so crediting every reported calorie systematically overstates the budget. How much is clawed back varies with body composition. WorkoutCompensationCalc turns a body fat reading — or, failing that, the user's BMI — into a suggested multiplier, interpolating between the two ends of the gradient in Careau et al. 2021 (Current Biology 31(20), PMID 34453886). Body fat percentiles come from the NCHS NHANES 1999-2004 DXA reference tables. Where the app is approximating rather than quoting the sources, the doc comment says so and says why; a user with neither input usable gets the sample-wide mean and nothing is invented from thin air.
Finished workouts from Health Connect / Apple Health are filed as
ordinary activities: editable, deletable, and raising the day's calorie
and macro goals through the same path a manually logged workout takes.
What marks them is the platform record id, which is what stops the same
record being imported twice when the read window deliberately overlaps
the last one — fitness apps routinely sync a session hours after it
finished, and a window starting exactly where the previous ended would
miss those forever.
Details worth knowing:
* a workout is filed under the logical day its start falls in, so a
00:30 session with an 03:00 day boundary raises the previous day's
goal, not today's (simonoppowa#139);
* the credited energy is the device figure times the configured
multiplier, while the raw figure is kept alongside it;
* records with no energy, zero energy, or no duration are skipped
rather than landing as noise;
* known workout types map onto compendium activities for their name
and icon — the MET value is never consulted, since the device's own
energy figure overrides it — and unmapped ones become a Custom
activity labelled with the platform's own type name.
Triggered at app start and on resume, both through a debounced entry
point that is inert until the user opts in and that logs rather than
propagates failures. The user-initiated path throws instead, so a
revoked permission can be surfaced rather than silently swallowed.
Read-only access to exercise sessions and body fat, requested lazily once workout import is switched on. Adds the Health Connect package query, the permission-rationale intent filter and the Android 14 permission-usage activity-alias required by the health plugin, and hosts Flutter in a FlutterFragmentActivity so the plugin can drive registerForActivityResult.
NSHealthShareUsageDescription only — the feature never writes back, so no update description is declared.
HealthKit authorization is rejected without the entitlement. The App ID needs the HealthKit capability enabled and the match profiles regenerated before the next signed iOS build.
Sixteen keys covering the settings entry, the health sync sub-screen, the imported-activity name and the energy-compensation source entry, in all nine locales.
…reen Careau et al. (2021) backs the suggested workout calorie credit. The entry also records that imported workout energy comes from the recording app or device rather than the in-app MET formula.
Workout import reads exercise sessions and body fat percentage, so the blanket "no health-data access" claim no longer holds.
Completes the config chain for the two workout-import settings: the loaded state now mirrors them like every other persisted preference, and the setters delegate to AddConfigUsecase.
Opting in asks the platform for read access, and only on a grant does it enable the import, seed the calorie credit from the user's body composition and pull the backlog in. A refusal reverts the switch and says what was refused rather than leaving a switch that looks on and does nothing. The credit slider moves in whole 5% steps between the bounds the config entity defines, persists on release, and offers the computed suggestion whenever it differs from what is stored. "Import now" reports how many workouts it filed and refreshes the diary when it filed any. A device without a health store still gets the row, and the screen explains why nothing on it can be used.
Pins the behaviour that is easy to regress silently: the slider stays inert and no permission is requested until the user opts in, a granted opt-in seeds the suggested credit, a denied one leaves the setting off and shows why, and the suggestion is only offered when acting on it would move the slider.
The activity schema round-trips externalId and sourceReportedKcal (and the older userKcal), none of which the sample record showed.
Unmapped workouts ended up named from the platform type string, so activityImportedWorkout never gained a caller in any locale.
userKcal is set the moment the user edits an activity's calories, manual or imported alike; only externalId and sourceReportedKcal are import-only.
The health plugin fills a workout's totals by reading the session's associated Distance, TotalCaloriesBurned and Steps records, and Health Connect rejects the whole workout query with a SecurityException when any of those reads is missing — the plugin then swallows the error and answers with an empty list, so every import quietly filed nothing. Request and declare the three extra reads on Android (HealthKit carries the totals on the workout itself, so iOS keeps the two-type list), and check the grants before reading so a revoked permission fails the import loudly instead of advancing the watermark over a window that was never actually read.
"Health Connect" / "Apple Health" is the name users recognize, so the settings row and the screen use it directly instead of a generic "Health sync" label; the subtitle drops the platform placeholder the title now carries.
The plugin fills an Android workout's totalEnergyBurned by summing every TOTAL_CALORIES_BURNED record overlapping the session window regardless of which app wrote it. Alongside an all-day calorie stream (Fitbit and Google Fit write basal+activity in 15-minute buckets) that inflates a real workout by the background energy the calorie goal already models — a 430 kcal gym session imported as 847 kcal on a device with a Fitbit stream running. Read the raw calorie records instead and sum per source: the session writer's own records when it wrote any, otherwise the largest single-source sum in the window, never the cross-source total. Records belong to the window by start time, matching Health Connect's between() semantics. iOS is unchanged — HKWorkout carries its own total.
There was a problem hiding this comment.
Pull request overview
Adds opt-in Health Connect/Apple Health workout imports, calorie-credit adjustment, provenance storage, and automatic/manual synchronization.
Changes:
- Adds platform health permissions, service integration, import/deduplication, and activity logging.
- Adds health-sync settings, localization, compensation calculation, and sources.
- Extends persistence/export models and adds comprehensive tests.
Reviewed changes
Copilot reviewed 51 out of 54 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
test/unit_test/workout_compensation_calc_test.dart |
Tests compensation calculations. |
test/unit_test/import_workouts_usecase_test.dart |
Tests workout import behavior. |
test/unit_test/health_package_service_test.dart |
Tests Android energy attribution. |
test/unit_test/config_health_fields_test.dart |
Tests health configuration defaults. |
test/features/settings/presentation/widgets/health_sync_screen_test.dart |
Tests health-sync UI. |
README.md |
Documents health-data privacy. |
pubspec.yaml |
Adds the health plugin. |
pubspec.lock |
Locks health dependencies. |
lib/main.dart |
Starts background import. |
lib/l10n/intl_zh.arb |
Adds Chinese strings. |
lib/l10n/intl_uk.arb |
Adds Ukrainian strings. |
lib/l10n/intl_tr.arb |
Adds Turkish strings. |
lib/l10n/intl_sk.arb |
Adds Slovak strings. |
lib/l10n/intl_pl.arb |
Adds Polish strings. |
lib/l10n/intl_it.arb |
Adds Italian strings. |
lib/l10n/intl_en.arb |
Adds localization templates. |
lib/l10n/intl_de.arb |
Adds German strings. |
lib/l10n/intl_cs.arb |
Adds Czech strings. |
lib/features/settings/settings_screen.dart |
Adds the health-sync entry. |
lib/features/settings/presentation/widgets/health_sync_screen.dart |
Implements health-sync controls. |
lib/features/settings/presentation/bloc/settings_state.dart |
Exposes health settings state. |
lib/features/settings/presentation/bloc/settings_bloc.dart |
Persists health settings. |
lib/features/home/home_page.dart |
Imports workouts on resume. |
lib/features/add_activity/presentation/widgets/quick_add_activity_bottom_sheet.dart |
Reuses centralized activity logging. |
lib/core/utils/url_const.dart |
Adds research URL. |
lib/core/utils/locator.dart |
Registers health dependencies. |
lib/core/utils/calc/workout_compensation_calc.dart |
Calculates suggested credit. |
lib/core/presentation/sources_screen.dart |
Adds compensation research. |
lib/core/domain/usecase/log_user_activity_usecase.dart |
Centralizes activity and goal updates. |
lib/core/domain/usecase/import_workouts_usecase.dart |
Implements workout import. |
lib/core/domain/usecase/add_config_usecase.dart |
Adds health-setting setters. |
lib/core/domain/entity/user_activity_entity.dart |
Adds workout provenance. |
lib/core/domain/entity/config_entity.dart |
Adds health configuration. |
lib/core/data/repository/user_activity_repository.dart |
Exposes external-ID lookup. |
lib/core/data/repository/health_import_repository.dart |
Wraps the health service. |
lib/core/data/repository/config_repository.dart |
Persists health configuration. |
lib/core/data/dbo/config_dbo.g.dart |
Updates generated config adapter. |
lib/core/data/dbo/config_dbo.dart |
Stores health configuration. |
lib/core/data/data_source/user_activity_dbo.g.dart |
Updates generated activity adapter. |
lib/core/data/data_source/user_activity_dbo.dart |
Stores workout provenance. |
lib/core/data/data_source/user_activity_data_source.dart |
Preserves and queries external IDs. |
lib/core/data/data_source/health/noop_health_service.dart |
Handles unsupported platforms. |
lib/core/data/data_source/health/health_service.dart |
Defines the health abstraction. |
lib/core/data/data_source/health/health_service_factory.dart |
Selects a health implementation. |
lib/core/data/data_source/health/health_package_service.dart |
Integrates the health plugin. |
lib/core/data/data_source/health/external_workout.dart |
Models platform workouts. |
lib/core/data/data_source/config_data_source.dart |
Adds health-setting persistence. |
ios/Runner/Runner.entitlements |
Enables HealthKit. |
ios/Runner/Info.plist |
Adds HealthKit usage text. |
ios/Runner.xcodeproj/project.pbxproj |
Applies HealthKit entitlements. |
docs/export-format.md |
Documents provenance fields. |
android/app/src/main/kotlin/com/opennutritracker/ont/opennutritracker/MainActivity.kt |
Uses FlutterFragmentActivity. |
android/app/src/main/AndroidManifest.xml |
Declares Health Connect access. |
android/app/build.gradle |
Raises Android minimum SDK. |
Files not reviewed (2)
- lib/core/data/data_source/user_activity_dbo.g.dart: Generated file
- lib/core/data/dbo/config_dbo.g.dart: Generated file
Suppressed comments (1)
lib/features/settings/presentation/widgets/health_sync_screen.dart:367
- Project convention requires every new interactive widget to have a stable
Semantics(identifier:)wrapper (AGENTS.md:158–179). Add an identifier to the action that applies the suggested multiplier.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @override | ||
| Future<bool> requestPermissions() async { | ||
| return await _health.requestAuthorization( | ||
| _readTypes, | ||
| permissions: List.filled(_readTypes.length, HealthDataAccess.READ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Fixed in 24b2ee9 — after requestAuthorization returns true, requestPermissions now re-checks hasPermissions on the workout read types and reports failure only on a definite false. null (iOS never reports read grants) still passes, matching the readWorkouts guard, which is unchanged. Body fat stays optional.
| @HiveField(33) | ||
| bool? healthImportEnabled; |
There was a problem hiding this comment.
Fixed in 3a5a40c — all the health fields (opt-in flag, multiplier, watermark, and the deleted-ids list added by 00fbd95) are now overlaid from the per-profile config box in _readMerged, in both branches, so each profile opts in independently, imports its own copy and keeps its own watermark; a fresh profile reads as opted out. They belong with the personal fields because the activity box they write into is itself per-profile.
| final workouts = await _healthImportRepository.getWorkouts( | ||
| from: from, | ||
| to: to, | ||
| ); | ||
| final seenIds = await _userActivityRepository.getExternalIdsSince(from); |
There was a problem hiding this comment.
Fixed in 508b442 — one shared in-flight future now covers the whole read → dedupe → write → watermark sequence. importNow joins an in-flight run instead of starting a second one (still returns the real count, still throws on failure), and importIfDue joins too rather than re-reading the platform the moment a run finishes. Serialization tests added; verified they fail with the guard removed.
| // Pull in any workouts logged since the last run. Inert unless the user has | ||
| // opted in, self-debouncing, and it swallows its own failures — the app | ||
| // must never wait on, or fail to start because of, the health store. | ||
| unawaited(locator<ImportWorkoutsUsecase>().importIfDue()); |
There was a problem hiding this comment.
Fixed in 325e343 — the bootstrap import is removed; the launch import now runs from HomePage initState through the same path as the resume import, so it reaches the same Home/Diary/Calendar refresh when something came in. Still non-blocking and failure-swallowing.
| _physicalActivityFor(workout, activityByCode), | ||
| // Mirrored into burnedKcal above because the aggregation layer sums | ||
| // that field; userKcal records that the figure is a measured one rather | ||
| // than something the MET formula produced, so editing prefills it. | ||
| userKcal: creditedKcal, | ||
| externalId: workout.id, | ||
| sourceReportedKcal: reportedKcal, |
There was a problem hiding this comment.
Fixed in 9fee85d — an activity with an externalId no longer goes through the MET recompute: the credited kcal scales proportionally with the edited duration (half the time credits half the calories), on both burnedKcal and userKcal since the importer writes them as a pair. sourceReportedKcal stays exactly what the device reported and externalId is kept for dedupe. Chosen over a MET recompute because the device measurement is better information than the formula estimate.
| Future<Set<String>> getExternalIdsSince(DateTime from) async { | ||
| return _userActivityBox.values | ||
| .where( | ||
| (activity) => | ||
| activity.externalId != null && !activity.date.isBefore(from), | ||
| ) | ||
| .map((activity) => activity.externalId!) | ||
| .toSet(); |
There was a problem hiding this comment.
Fixed in 00fbd95 — deleting an activity with an externalId now records the id in a per-profile tombstone list (a new config field), and the importer unions the tombstones into the dedupe set, so the deletion survives the overlapping re-read. The list grows only by user deletions, so it is kept indefinitely.
| Records also carry the optional `userKcal` (set once the user edits an | ||
| activity's calories, `null` before that), plus `externalId` (the health-store | ||
| record an imported workout came from, which is what stops a re-import | ||
| duplicating it) and `sourceReportedKcal` (the energy the device reported before | ||
| the workout calorie credit); the latter two are `null` on manually logged | ||
| activities, and all three round-trip on import/export. |
There was a problem hiding this comment.
Fixed in 2fc0891 — user_kcal, external_id and source_reported_kcal are now written and parsed (appended after the original columns; the header-driven parser still reads older CSVs without them), and the export-format doc table covers all three.
| IconButton( | ||
| icon: const Icon(Icons.info_outline_rounded), | ||
| tooltip: s.sourcesIconTooltip, | ||
| onPressed: _openSources, | ||
| ), |
There was a problem hiding this comment.
Fixed in b59f16c — both buttons now carry Semantics identifiers (health-sync-sources, health-sync-apply-suggestion), the latter with container: true since it sits inside a layout-greedy Align.
| Expanded( | ||
| child: Text( | ||
| s.healthSyncKcalMultiplierLabel, | ||
| style: textTheme.titleMedium, | ||
| ), |
There was a problem hiding this comment.
Fixed in b59f16c — the section title is now an AutoSizeText (maxLines: 1, minFontSize: 11, ellipsis) inside its existing Expanded.
| sources: const [ | ||
| _SourceLink( | ||
| citation: | ||
| 'Careau V, et al. (2021). Energy compensation and adiposity ' | ||
| 'in humans. Current Biology, 31(20):4659–4666.e2.', | ||
| url: URLConst.sourceEnergyCompensationCareau2021URL, | ||
| ), |
There was a problem hiding this comment.
Fixed in b6ba26b — added Borrud LG et al. (2010), NCHS Vital and Health Statistics Series 11 No. 250 as a second source link under the energy-compensation entry, mirroring the citation already given in the calculator source.
pod install regenerated the lockfile for the new health and device_info_plus (transitive) plugin pods; committed from the CI runner's own pod install output, as no macOS machine is available locally.
Android's requestAuthorization answers true as soon as any requested type was granted, so a user who ticked body fat and left the workout records unticked switched the import on with nothing importable behind it. Verify the workout read types afterwards; body fat stays optional, and a platform that will not report read grants (iOS) still counts as granted.
App start, every app resume and the settings screen's "import now" all call into the same use case, and the read → dedupe → write → watermark sequence has several awaits in it. Two overlapping runs each read the same window, each find nothing filed against the record yet, and each write it: one session logged twice, the day's calorie and macro goals raised twice. Hold the run in flight and hand it to whoever arrives while it lasts, so the sequence has one writer at a time. The manual path still gets a real count back and still raises whatever the health store threw.
Bootstrap fired the import and dropped the result on the floor, so a workout picked up at launch only appeared once something else happened to reload the day. The resume path already refreshes the home, diary and calendar-day blocs when anything came in — run the launch import from the same place so it gets the same treatment. Still off the startup critical path, and still failure-swallowing.
Editing an imported workout's duration ran it through the MET formula like any other compendium activity, which replaced the device-reported energy — the whole point of the import — with an estimate off a compendium value the importer never consulted. Scale the credited calories with the duration instead: half the time, half the credit. sourceReportedKcal stays exactly what the device said, and externalId stays put so the next import still recognises the record.
The merged config read only overlaid the nutrition goals from the active profile's box, so the app box's copy of the health opt-in, the calorie multiplier and the import watermark always won. Every profile therefore shared one switch and one watermark — switching profile carried the opt-in across, and whichever profile imported last moved the window for all of them. The activity box those imports land in is per-profile, so the settings that drive them belong there too: overlay them from the profile box, and clear them along with the other personal fields when there is no profile box to read, so a fresh profile starts opted out with a watermark of its own.
The importer dedupes against the external ids of the activities on file and deliberately re-reads a 24-hour overlap past its watermark, so deleting an imported workout removed the only thing standing between that record and a second import: the next run filed it straight back, goals and all. Remember the external id of every imported activity the user deletes and skip it on later reads. The list lives on the profile config next to the rest of the health settings, so it is scoped the same way the activities are, and it is kept indefinitely — dropping an entry resurrects the workout it stands for, and it only ever grows by one entry per deletion.
The user_activity CSV stopped at the ten original columns, so userKcal, externalId and sourceReportedKcal were dropped on export — an imported workout came back from the round trip the docs promise as an ordinary MET-derived activity, without the record id that dedupes it. Append the three as optional columns and read them back. Appended rather than inserted, and the parser is header-driven, so a CSV written by an older build still parses with them null.
The sources button and the "apply the suggestion" button had no accessibility identifier, so the per-branch UI verifier could not drive them, and the section title was a plain Text sharing a Row with the percentage and the button — a long localized label would have wrapped or striped the row. Give both buttons an identifier (with container: true on the one inside an Align, whose bounds it would otherwise inherit) and let the title shrink to fit inside its existing Expanded.
The energy-compensation entry credited only Careau et al. for the calorie-credit suggestion, but the sex-specific body fat percentiles the calculator interpolates between come from the NCHS body-composition report, not that paper. List it alongside so the screen accounts for both halves of the calculation.
Closes #147. Also relevant to the broader health-sync discussion in #103.
Adds an opt-in import of workouts from Health Connect (Android) / Apple Health (iOS) as diary activities.
What it does
Decisions maintainers should weigh in on
665ac92): forced by the Health Connect client library. This drops Android 7.x/8.0 support — flagging it explicitly since it's a user-facing floor change.a96b173,8f0a1a2): besides exercise sessions, the manifest declares read access for total calories burned, distance, and steps. Thehealthplugin fills a workout's totals by issuing those sub-reads per session, and Health Connect rejects the whole workout query with aSecurityExceptionif any of them is not granted.6a6f10c): on Android, a session's calories are attributed from rawTotalCaloriesBurnedrecords written by the session's own app, rather than the plugin's pre-summed total. The plugin sums every calorie record overlapping the session window regardless of writer, so an all-day background stream (Fitbit / Google Fit write basal+activity in 15-minute buckets — energy the TDEE-based goal already models) double-counts on top of the workout app's own figure; observed on-device as a 430 kcal gym session importing as 847 kcal. When the session writer logged no calories, the largest single-source sum in the window is used — never the cross-source total. iOS is unchanged (HKWorkout carries its own total).3d0c06d): touches the Runner target's signing surface, which has consequences for release provisioning. Happy to revert cleanly if you'd rather wire the entitlement yourselves.healthpinned at^13.1.4: 13.3.x pulls awin32version that conflicts withpackage_info_plus ^9.Notes
dart formated; pre-existing repo-wide format drift is left untouched (CI does not enforce format).Testing