feat(platform,settings,tests): complete Phase 3B platform health integrations and HealthGateway end-to-end

Implements architecture section 4.7: IHealthGateway now exposes the Phase 3B contract (HealthProviderType, HealthConnectionStatus, HealthDataPayload, checkStatus/requestAuthorization/fetchLatestBiometrics/disconnect) with defaults derived from the existing primitives. Drivers and test fakes switch from implements to extends so Dart inherits the default bodies.

- Mobile driver: BLOOD_GLUCOSE added to iOS/Android read sets (mmol/L to mg/dL normalization); checkStatus probes hasPermissions plus Health Connect SDK readiness; samsungHealth aliases the Health Connect binding on Android (federated Samsung/Pixel feeds); disconnect clears the cached SDK probe.

- Fallback driver: safe dummy states on Windows/Web/macOS (manualFallback connected, enterpriseFhir notConfigured, native unsupportedPlatform); fetchLatestBiometrics serves the encrypted drift cache; zero platform-channel calls.

- Settings: IntegrationsController rewritten from toggles to status orchestration (refreshStatuses, connect/disconnect round-trips, denial to permissionDenied plus snackbar); SettingsScreen gains a Health Integrations card with Apple Health, Health Connect (Samsung & Pixel), and SMART on FHIR rows showing live status badges and Connect/Disconnect buttons with desktop-safe disabled states.

- Manifests: Android adds READ_BLOOD_GLUCOSE, healthdata package query, ACTION_SHOW_PERMISSIONS_RATIONALE intent, and ViewPermissionUsageActivity alias; iOS adds NSHealthShareUsageDescription and NSHealthUpdateUsageDescription.

- Tests: +13 (fallback dummy-state matrix, integrations controller transitions, Settings desktop disabled-states and mobile connect/disconnect round trip); existing fake switched to extends; navigation test updated for the renamed card title.

Verified: build_runner 191 outputs; flutter analyze clean; flutter test 148/148; flutter build windows debug succeeded.
This commit is contained in:
Mike Kell 2026-10-17 12:01:59 -04:00
parent 2f818ab850
commit ddcdefa596
14 changed files with 1193 additions and 111 deletions

View File

@ -3,11 +3,13 @@
## Active Target ## Active Target
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE - **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done) - **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
- **Latest Addendum:** Phase 3B retrofit — Platform Health Integrations & HealthGateway end-to-end (2026-10-17)
## Phase Checklist ## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling - [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync - [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
- [x] Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements)
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar - [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator - [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD - [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
@ -85,6 +87,21 @@
## Post-Phase-7 Additions ## Post-Phase-7 Additions
- [x] App icon branding (2026-10-16): replaced all stock Flutter icons with the "cg" leaf monogram brand mark. Master asset `assets/icon/app_icon.jpg` (2048×2048); `assets/icon/generate_icons.py` (Python/Pillow, Lanczos) regenerates every platform size idempotently: web (`favicon.png` 32, `icons/Icon-{192,512}.png` with feathered circular alpha, `icons/Icon-maskable-{192,512}.png` at 80% safe-zone over brand sage `#D4E4C7`), Android (legacy `mipmap-*/ic_launcher.png` 48–192 px + new adaptive icons: `mipmap-anydpi-v26/ic_launcher{,_round}.xml`, `drawable/ic_launcher_background.xml` sage vector, `drawable-*/ic_launcher_foreground.png` 66/108 dp safe circle), iOS (all 15 `AppIcon.appiconset` PNGs — opaque RGB, no alpha, per App Store validation), macOS (`app_icon_{16..1024}.png`), Windows (`app_icon.ico` multi-res 16–256 px). Web polish: `manifest.json` name/description/theme (`#375A53` teal, `#D4E4C7` sage) and `index.html` title/description/`theme-color` updated from Flutter placeholders. Verified: PIL dimension/mode/alpha assertions on every output + XML well-formedness + ICO frame enumeration; `flutter build web --no-tree-shake-icons` and `flutter build apk --debug` package the new assets. - [x] App icon branding (2026-10-16): replaced all stock Flutter icons with the "cg" leaf monogram brand mark. Master asset `assets/icon/app_icon.jpg` (2048×2048); `assets/icon/generate_icons.py` (Python/Pillow, Lanczos) regenerates every platform size idempotently: web (`favicon.png` 32, `icons/Icon-{192,512}.png` with feathered circular alpha, `icons/Icon-maskable-{192,512}.png` at 80% safe-zone over brand sage `#D4E4C7`), Android (legacy `mipmap-*/ic_launcher.png` 48–192 px + new adaptive icons: `mipmap-anydpi-v26/ic_launcher{,_round}.xml`, `drawable/ic_launcher_background.xml` sage vector, `drawable-*/ic_launcher_foreground.png` 66/108 dp safe circle), iOS (all 15 `AppIcon.appiconset` PNGs — opaque RGB, no alpha, per App Store validation), macOS (`app_icon_{16..1024}.png`), Windows (`app_icon.ico` multi-res 16–256 px). Web polish: `manifest.json` name/description/theme (`#375A53` teal, `#D4E4C7` sage) and `index.html` title/description/`theme-color` updated from Flutter placeholders. Verified: PIL dimension/mode/alpha assertions on every output + XML well-formedness + ICO frame enumeration; `flutter build web --no-tree-shake-icons` and `flutter build apk --debug` package the new assets.
## Phase 3B Deliverables (2026-10-17, architecture §4.7 retrofit)
- [x] Platform contract (`lib/core/platform/health_gateway.dart`): Phase 3B surface added — `HealthProviderType` (appleHealth, healthConnect, samsungHealth, enterpriseFhir, manualFallback), `HealthConnectionStatus` (notConfigured, permissionDenied, connected, unsupportedPlatform), `HealthDataPayload` (weightLbs / activeCaloriesToday / bloodGlucoseMgDl / timestamp / source), and `IHealthGateway.checkStatus` / `requestAuthorization` / `fetchLatestBiometrics` / `disconnect` with defaults derived from the existing primitives. NOTE for future implementors: Dart `implements` never inherits method bodies — drivers and test fakes must `extends IHealthGateway` (or reimplement the members) to pick up the defaults.
- [x] Platform (mobile driver): `BLOOD_GLUCOSE` added to the iOS/Android `package:health` read sets (mmol/L → mg/dL normalization ×18.0182); `checkStatus` probes `hasPermissions` + Health Connect SDK readiness before answering; `samsungHealth` aliases the Health Connect binding on Android (federated Samsung Health + Pixel sensor feeds); `disconnect` clears the cached SDK probe (neither HealthKit nor Health Connect exposes programmatic revocation — OS-level revoke stays in system settings); `fetchLatestBiometrics` aggregates today's weight (latest), active energy (sum), and blood glucose (latest).
- [x] Platform (fallback driver): inherits the Phase 3B defaults — manualFallback → `connected`, enterpriseFhir → `notConfigured`, native providers → `unsupportedPlatform`; `fetchLatestBiometrics` composes the encrypted drift cache via `ILocalBiometricReader` with source `manualFallback`; zero platform-channel calls on Web/Windows/macOS (safe dummy states, never a crash).
- [x] Presentation (`IntegrationsController` rewrite): status-based orchestration replacing the old toggles — `refreshStatuses()` probes all three providers on build (disposal-flag guarded; Riverpod 2.x `Ref` has no `mounted`), `connectAppleHealth`/`connectHealthConnect` run `requestAuthorization` round-trips (denial → `permissionDenied` + inline snackbar), `disconnectProvider` tears down via the gateway and re-probes.
- [x] Presentation (`SettingsScreen`): `Health Integrations` card with Apple Health, Health Connect (Samsung & Pixel), and SMART on FHIR rows — live status badges (Connected / Not connected / Permission denied / Unsupported) and Connect/Disconnect authorization buttons; desktop/web render disabled Connect buttons + neutral grey "Unsupported" badges (desktop-safe states, no dead platform channels); the FHIR row stays inert until its OAuth2 connector is provisioned. Identity (Supabase sign-out), Targets, and SQLCipher status cards unchanged; `/settings` route wiring unchanged.
- [x] Manifests: Android — `android.permission.health.READ_BLOOD_GLUCOSE` added to the Health Connect permission set, `com.google.android.apps.healthdata` package query + `ACTION_SHOW_PERMISSIONS_RATIONALE` intent in `<queries>`, and `ViewPermissionUsageActivity` activity-alias (`VIEW_PERMISSION_USAGE` / `HEALTH_PERMISSIONS`) per architecture §4.7.4; iOS — `NSHealthShareUsageDescription` + `NSHealthUpdateUsageDescription` added to `ios/Runner/Info.plist`.
- [x] Tests: +13 — fallback driver dummy-state matrix (`fallback_health_gateway_test.dart`, 5), integrations controller connect/disconnect/status transitions (`integrations_controller_test.dart`, 6), Settings screen desktop disabled-states + mobile connect/disconnect badge round trip (`settings_screen_test.dart`, 2). Existing `_FakeHealthGateway` switched to `extends` for the inherited defaults; `app_navigation_test.dart` updated for the renamed "Health Integrations" card title.
## Phase 3B Verification Gates (2026-10-17)
- [x] `dart run build_runner build --delete-conflicting-outputs` (191 outputs written)
- [x] `flutter analyze` (No issues found!)
- [x] `flutter test` (148/148 passing)
- [x] `flutter build windows --debug` (`✓ Built build\windows\x64\runner\Debug\glpcompanion.exe`; the Settings screen's desktop-safe disabled states are asserted headlessly by `settings_screen_test.dart` — interactive `flutter run -d windows` compiles the same toolchain)
## Active Verification Gates (Phase 7, 2026-10-16) ## Active Verification Gates (Phase 7, 2026-10-16)
- [x] `dart analyze lib test` (Zero issues) - [x] `dart analyze lib test` (Zero issues)
- [x] `flutter test` (all passing — 125/125) - [x] `flutter test` (all passing — 125/125)

View File

@ -5,11 +5,13 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching <!-- Health Connect (Android 14+ / Play services): read permissions matching
the HealthGateway request set (weight, steps, total calories burned, the HealthGateway request set (weight, steps, total calories burned,
active energy burned). --> active energy burned, blood glucose). Health Connect federates
Samsung Health and Pixel sensor feeds through this runtime. -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/> <uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/> <uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/> <uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/> <uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
<application <application
android:label="glpcompanion" android:label="glpcompanion"
android:name="${applicationName}" android:name="${applicationName}"
@ -52,6 +54,18 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Health Connect: lets the OS deep-link into this app's entry point
from the permission-usage screen ("View app permission usage"). -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:targetActivity=".MainActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@ -63,5 +77,11 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- Health Connect: allow querying the installed Health Store app and
its permission-rationale activity (architecture section 4.7.4). -->
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -86,6 +86,136 @@ Within each feature module in `lib/features/<feature_name>/`, separation of conc
| **Push Notifications** | `flutter_local_notifications` | Cross-platform offline-scheduled alerts for injections and hydration checks. | | **Push Notifications** | `flutter_local_notifications` | Cross-platform offline-scheduled alerts for injections and hydration checks. |
--- ---
### 4.7 Phase 3B: Platform Health Integrations & HealthGateway Architecture
#### 4.7.1 System Overview
The `HealthGateway` subsystem provides an abstracted, platform-agnostic bridge between application domains (Nutrition, Biometrics, Exercise) and external health telemetry ecosystems. It decouples high-level clinical metric analysis from platform-specific APIs (Apple HealthKit, Google Health Connect, Samsung Health) and external enterprise standards (SMART on FHIR), ensuring full cross-platform stability across iOS, Android, macOS, Windows, and Web.
```text
┌──────────────────────────────────┐
│ IHealthGateway (Core) │
└─────────────────┬────────────────┘
│
┌─────────────────────────┬───────────────┴────────────────┬────────────────────────┐
│ (iOS / macOS) │ (Android) │ (Cloud / Enterprise) │ (Windows / Web)
▼ ▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Apple HealthKit │ │ Health Connect │ │ SMART on FHIR │ │ Local Fallback │
│ - Weight (lbs) │ │ & Samsung Health│ │ OAuth2 / REST │ │ - Manual inputs │
│ - Active Energy │ │ - Weight (lbs) │ │ - HbA1c panels │ │ - Local SQLite │
│ - Blood Glucose │ │ - Active Burn │ │ - Lipid markers │ │ persistence │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
```
#### 4.7.2 Core Gateway Contracts & Data Payloads
```dart
// lib/core/platform/health_gateway.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
enum HealthProviderType {
appleHealth,
healthConnect,
samsungHealth,
enterpriseFhir,
manualFallback,
}
enum HealthConnectionStatus {
notConfigured,
permissionDenied,
connected,
unsupportedPlatform,
}
class HealthDataPayload {
final double? weightLbs;
final double? activeCaloriesToday;
final double? bloodGlucoseMgDl;
final DateTime timestamp;
final HealthProviderType source;
const HealthDataPayload({
this.weightLbs,
this.activeCaloriesToday,
this.bloodGlucoseMgDl,
required this.timestamp,
required this.source,
});
}
abstract class IHealthGateway {
/// Verifies current OS entitlement and read permissions.
Future<HealthConnectionStatus> checkStatus(HealthProviderType provider);
/// Triggers native OS permission request prompt.
Future<bool> requestAuthorization(HealthProviderType provider);
/// Queries biometrics aggregated across the current calendar day.
Future<HealthDataPayload?> fetchLatestBiometrics();
/// Revokes active session bindings or tears down local connections.
Future<void> disconnect(HealthProviderType provider);
}
```
#### 4.7.3 Platform Support & Fallback Strategy
| Target Platform | Health Provider Bridge | Mechanism / Fallback |
|---|---|---|
| **iOS** | Apple HealthKit | Native `package:health` querying `HKQuantityTypeIdentifierBodyMass`, `HKQuantityTypeIdentifierActiveEnergyBurned`, `HKQuantityTypeIdentifierBloodGlucose`. |
| **Android** | Health Connect | `androidx.health.connect.client` routing both Google Pixel and Samsung Health sensor feeds via Health Connect runtime. |
| **Windows / macOS** | Local Mock / Manual DB Driver | Native SDKs bypassed via `kIsWeb || (!Platform.isIOS && !Platform.isAndroid)` compile-time and runtime guards. Biometrics route to local Drift SQLCipher storage without crash exceptions. |
| **Web** | Web Storage Fallback | Direct Supabase cloud-synced biometrics and manual modal logging; SDK initialization omitted. |
| **Enterprise Cloud** | SMART on FHIR | Headless OAuth2 code grant returning standardized HL7 FHIR `Observation` bundles (HbA1c, metabolic panels, weight). |
#### 4.7.4 Native Entitlements & Manifest Configurations
##### Android (`android/app/src/main/AndroidManifest.xml`)
```xml
<!-- Declare Health Connect integration and permissions -->
<manifest xmlns:android="[http://schemas.android.com/apk/res/android](http://schemas.android.com/apk/res/android)">
<queries>
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
</queries>
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
<application>
<!-- Activity-alias for Health Connect permissions rationale -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:targetActivity=".MainActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
</manifest>
```
##### iOS (`ios/Runner/Info.plist`)
```xml
<key>NSHealthShareUsageDescription</key>
<string>GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.</string>
```
#### 4.7.5 Security, Authorization & Key Management
1. **Zero Raw Token Exposure:** Enterprise SMART on FHIR refresh and access tokens are held exclusively in hardware-backed storage (`flutter_secure_storage` utilizing Android Keystore and Apple Keychain).
2. **Read-Only Minimal Principle:** Scopes requested from HealthKit and Health Connect are strictly restricted to read permissions for weight, blood glucose, and active burn to maximize user trust and prevent third-party telemetry leakage.
3. **Local Encryption at Rest:** Any synced telemetry data cached locally is stored inside the SQLCipher-encrypted SQLite database (`glp1_encrypted.db`), keyed by the hardware-derived master key managed by `CryptoManager`.
## 4. Multiplatform UX/UI Adaptation Engine ## 4. Multiplatform UX/UI Adaptation Engine

View File

@ -24,6 +24,10 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSHealthShareUsageDescription</key>
<string>GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'health_gateway_fallback.dart' import 'health_gateway_fallback.dart'
if (dart.library.io) 'health_gateway_mobile.dart' as impl; if (dart.library.io) 'health_gateway_mobile.dart' as impl;
@ -14,10 +16,69 @@ enum NativeHealthPlatform {
healthConnect, healthConnect,
} }
/// Specific health ecosystem a caller wants to address (Phase 3B contract,
/// architecture §4.7.2). [samsungHealth] federates through Health Connect on
/// Android; [enterpriseFhir] is the SMART on FHIR cloud connector (provisioning
/// pending); [manualFallback] is the always-available desktop/web mode.
enum HealthProviderType {
appleHealth,
healthConnect,
samsungHealth,
enterpriseFhir,
manualFallback,
}
/// Entitlement / read-permission state reported by
/// [IHealthGateway.checkStatus] for a [HealthProviderType].
enum HealthConnectionStatus {
/// Provider is supported but no permission decision exists yet.
notConfigured,
/// The OS health store explicitly refused (or revoked) access.
permissionDenied,
/// Entitlements + read permissions are granted.
connected,
/// The provider cannot run on this device/OS (e.g. HealthKit on Windows).
unsupportedPlatform,
}
/// Day-aggregated biometric snapshot returned by
/// [IHealthGateway.fetchLatestBiometrics] (architecture §4.7.2).
///
/// Every field is nullable: a platform may grant access to weight but not to
/// blood glucose (or vice versa), and the payload simply reports what it saw.
class HealthDataPayload {
const HealthDataPayload({
this.weightLbs,
this.activeCaloriesToday,
this.bloodGlucoseMgDl,
required this.timestamp,
required this.source,
});
/// Latest body-mass reading of the current calendar day, in pounds.
final double? weightLbs;
/// Summed active energy expenditure for the current calendar day, in kcal.
final double? activeCaloriesToday;
/// Latest blood-glucose reading of the current calendar day, in mg/dL
/// (mmol/L samples are normalized by the gateway).
final double? bloodGlucoseMgDl;
/// When the snapshot was assembled.
final DateTime timestamp;
/// Ecosystem the numbers came from.
final HealthProviderType source;
}
/// Scalar sample returned by the health gateway. /// Scalar sample returned by the health gateway.
/// ///
/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit /// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
/// normalization (kg → lb, joules → kcal) happens inside the gateway so /// normalization (kg → lb, joules → kcal) happens inside the gateway so
/// consumers only ever deal in pounds and Calories. /// consumers only ever deal in pounds and Calories.
class HealthMetric { class HealthMetric {
const HealthMetric({ const HealthMetric({
@ -103,6 +164,91 @@ abstract class IHealthGateway {
required double weightLbs, required double weightLbs,
required DateTime timestamp, required DateTime timestamp,
}); });
// ---------------------------------------------------------------------
// Phase 3B contract (architecture §4.7.2). Default implementations are
// derived from the primitives above so hand-written fakes and future
// cloud drivers only override what they need.
// ---------------------------------------------------------------------
/// The [HealthProviderType] this device's driver actually binds to, or
/// `null` in fallback mode (Web/Windows/macOS).
HealthProviderType? get activeProvider {
if (!supportsNativeHealth) return null;
final platform = nativePlatform;
if (platform == NativeHealthPlatform.appleHealth) {
return HealthProviderType.appleHealth;
}
if (platform == NativeHealthPlatform.healthConnect) {
return HealthProviderType.healthConnect;
}
return null;
}
/// Verifies current OS entitlement and read permissions for [provider].
///
/// Default: `manualFallback` is always available, `enterpriseFhir` is not
/// provisioned yet, native providers map through [activeProvider].
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider) async {
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
if (provider == HealthProviderType.enterpriseFhir) {
return HealthConnectionStatus.notConfigured;
}
final active = activeProvider;
if (active == null) {
return HealthConnectionStatus.unsupportedPlatform;
}
return provider == active
? HealthConnectionStatus.notConfigured
: HealthConnectionStatus.unsupportedPlatform;
}
/// Triggers the native OS permission request prompt for [provider].
///
/// Default: manual entry needs no grant; native providers route through
/// [requestPermissions]; mismatched providers are refused.
Future<bool> requestAuthorization(HealthProviderType provider) async {
if (provider == HealthProviderType.manualFallback) return true;
if (provider != activeProvider) return false;
return requestPermissions();
}
/// Queries biometrics aggregated across the current calendar day.
///
/// Default: composes [fetchWeightHistory] (latest) and
/// [fetchActiveEnergyBurned] (sum); drivers may override to add types such
/// as blood glucose. Returns `null` when nothing could be read.
Future<HealthDataPayload?> fetchLatestBiometrics() async {
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day);
final weights = await fetchWeightHistory(start: start, end: now);
final energies = await fetchActiveEnergyBurned(start: start, end: now);
if (weights.isEmpty && energies.isEmpty) return null;
var energyTotal = 0.0;
for (final sample in energies) {
energyTotal += sample.value;
}
return HealthDataPayload(
weightLbs: weights.isEmpty ? null : weights.first.value,
activeCaloriesToday: energies.isEmpty ? null : energyTotal,
timestamp: now,
source: activeProvider ?? HealthProviderType.manualFallback,
);
}
/// Revokes active session bindings or tears down local connections for
/// [provider]. Default: no-op (neither HealthKit nor Health Connect
/// exposes programmatic revocation to third-party apps — drivers may clear
/// cached readiness so the next [checkStatus] re-probes).
Future<void> disconnect(HealthProviderType provider) async {
debugPrint(
'HealthGateway: disconnect($provider) ignored - revoke access in the '
'system health settings.',
);
}
} }
/// Platform-appropriate factory: native `health`-backed gateway on /// Platform-appropriate factory: native `health`-backed gateway on

View File

@ -13,13 +13,18 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// (architecture rule 3: desktop/web get read-only manual entry until cloud /// (architecture rule 3: desktop/web get read-only manual entry until cloud
/// OAuth partners land in a later phase). /// OAuth partners land in a later phase).
/// ///
/// Extends [IHealthGateway] (rather than `implements`) so the interface's
/// Phase 3B defaults apply: `manualFallback` reports `connected`,
/// `enterpriseFhir` reports `notConfigured`, and native providers report
/// `unsupportedPlatform`.
///
/// It never touches a platform channel, so unsupported hardware calls can /// It never touches a platform channel, so unsupported hardware calls can
/// never crash: permissions are refused, writes are rejected, and reads are /// never crash: permissions are refused, writes are rejected, and reads are
/// served from the injected [ILocalBiometricReader] (the encrypted drift /// served from the injected [ILocalBiometricReader] (the encrypted drift
/// cache of locally logged biometrics) — or from nothing when no reader was /// cache of locally logged biometrics) — or from nothing when no reader was
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to /// wired. Consumers treat `supportsNativeHealth == false` as the signal to
/// open the manual-entry dialog. /// open the manual-entry dialog.
class FallbackHealthGateway implements IHealthGateway { class FallbackHealthGateway extends IHealthGateway {
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader}) FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
: _localReader = localBiometricReader { : _localReader = localBiometricReader {
// Debug-only diagnostic: makes the active driver obvious while // Debug-only diagnostic: makes the active driver obvious while

View File

@ -16,6 +16,9 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// `package:health`-backed gateway for Android (Health Connect) and iOS /// `package:health`-backed gateway for Android (Health Connect) and iOS
/// (HealthKit). /// (HealthKit).
/// ///
/// Extends [IHealthGateway] (rather than `implements`) so the interface's
/// Phase 3B defaults (`activeProvider`, `disconnect`, ...) are inherited.
///
/// Guard layers (architecture rule 2/3 — zero crashes on unsupported /// Guard layers (architecture rule 2/3 — zero crashes on unsupported
/// environments): /// environments):
/// ///
@ -31,7 +34,7 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// 4. **Defensive:** every native call is wrapped in `try/catch` so missing /// 4. **Defensive:** every native call is wrapped in `try/catch` so missing
/// HealthKit entitlements or hardware quirks can never surface as /// HealthKit entitlements or hardware quirks can never surface as
/// unhandled exceptions. /// unhandled exceptions.
class MobileHealthGateway implements IHealthGateway { class MobileHealthGateway extends IHealthGateway {
final Health _health = Health(); final Health _health = Health();
Future<void>? _configuration; Future<void>? _configuration;
@ -40,12 +43,15 @@ class MobileHealthGateway implements IHealthGateway {
/// Read set requested from Health Connect (Android), mirroring the /// Read set requested from Health Connect (Android), mirroring the
/// `READ_*` permissions declared in the Android manifest: weight plus the /// `READ_*` permissions declared in the Android manifest: weight plus the
/// activity metrics consumed by the nutrition dashboard. /// activity metrics and glycemic marker consumed by the nutrition and
/// biometric dashboards. Health Connect federates Samsung Health and
/// Pixel sensor feeds into this single runtime.
static const List<HealthDataType> _androidReadTypes = [ static const List<HealthDataType> _androidReadTypes = [
HealthDataType.WEIGHT, HealthDataType.WEIGHT,
HealthDataType.STEPS, HealthDataType.STEPS,
HealthDataType.TOTAL_CALORIES_BURNED, HealthDataType.TOTAL_CALORIES_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.BLOOD_GLUCOSE,
]; ];
/// Read set requested from HealthKit (iOS); requires the HealthKit /// Read set requested from HealthKit (iOS); requires the HealthKit
@ -54,6 +60,7 @@ class MobileHealthGateway implements IHealthGateway {
static const List<HealthDataType> _iosReadTypes = [ static const List<HealthDataType> _iosReadTypes = [
HealthDataType.WEIGHT, HealthDataType.WEIGHT,
HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.BLOOD_GLUCOSE,
]; ];
@override @override
@ -142,6 +149,102 @@ class MobileHealthGateway implements IHealthGateway {
} }
} }
/// Whether [provider] routes to this device's native SDK.
///
/// Samsung Health sensor feeds (Galaxy watches, body scales) federate
/// through Health Connect on Android, so [HealthProviderType.samsungHealth]
/// aliases the healthConnect binding there.
bool _providerMatches(HealthProviderType provider) {
final active = activeProvider;
if (active == null) return false;
if (provider == active) return true;
return Platform.isAndroid &&
provider == HealthProviderType.samsungHealth &&
active == HealthProviderType.healthConnect;
}
@override
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider,
) async {
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
if (provider == HealthProviderType.enterpriseFhir) {
return HealthConnectionStatus.notConfigured;
}
if (!supportsNativeHealth || !_providerMatches(provider)) {
return HealthConnectionStatus.unsupportedPlatform;
}
try {
await _configure();
if (Platform.isAndroid && !await _ensureHealthConnectReady()) {
return HealthConnectionStatus.unsupportedPlatform;
}
final granted = await _health.hasPermissions(
_readTypes,
permissions: _readPermissions,
);
return granted == true
? HealthConnectionStatus.connected
: HealthConnectionStatus.notConfigured;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'HealthGateway: checkStatus failed (non-fatal)',
);
return HealthConnectionStatus.unsupportedPlatform;
}
}
@override
Future<bool> requestAuthorization(HealthProviderType provider) async {
if (provider == HealthProviderType.manualFallback) return true;
if (!_providerMatches(provider)) return false;
return requestPermissions();
}
@override
Future<HealthDataPayload?> fetchLatestBiometrics() async {
if (!supportsNativeHealth) return null;
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day);
final weights = await fetchWeightHistory(start: start, end: now);
final energies = await fetchActiveEnergyBurned(start: start, end: now);
final glucose = await _fetch(
type: HealthDataType.BLOOD_GLUCOSE,
start: start,
end: now,
normalize: _glucoseInMgDl,
unit: 'mg/dL',
);
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
var energyTotal = 0.0;
for (final sample in energies) {
energyTotal += sample.value;
}
return HealthDataPayload(
weightLbs: weights.isEmpty ? null : weights.first.value,
activeCaloriesToday: energies.isEmpty ? null : energyTotal,
bloodGlucoseMgDl: glucose.isEmpty ? null : glucose.first.value,
timestamp: now,
source: activeProvider ?? HealthProviderType.healthConnect,
);
}
@override
Future<void> disconnect(HealthProviderType provider) async {
if (!_providerMatches(provider)) return;
// Neither HealthKit nor Health Connect exposes programmatic permission
// revocation to third-party apps; clearing the cached SDK probe forces
// the next [checkStatus] to re-evaluate the real system state.
_healthConnectReady = null;
debugPrint(
'HealthGateway: disconnect($provider) acknowledged - revoke access in '
'the system health settings.',
);
}
@override @override
Future<List<HealthMetric>> fetchWeightHistory({ Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start, required DateTime start,
@ -274,4 +377,9 @@ class MobileHealthGateway implements IHealthGateway {
/// Active energy normally arrives in kilocalories; normalize joules too. /// Active energy normally arrives in kilocalories; normalize joules too.
double _energyInKcal(double raw, HealthDataUnit unit) => double _energyInKcal(double raw, HealthDataUnit unit) =>
unit == HealthDataUnit.JOULE ? raw / 4.184 : raw; unit == HealthDataUnit.JOULE ? raw / 4.184 : raw;
/// HealthKit / CGM feeds may report blood glucose in mmol/L; normalize to
/// mg/dL (x18.0182) so the rest of the app only sees one unit.
double _glucoseInMgDl(double raw, HealthDataUnit unit) =>
unit == HealthDataUnit.MILLIMOLES_PER_LITER ? raw * 18.0182 : raw;
} }

View File

@ -4,18 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/platform/health_gateway.dart'; import '../../../../core/platform/health_gateway.dart';
import '../../../health_sync/presentation/controllers/biometric_controller.dart'; import '../../../health_sync/presentation/controllers/biometric_controller.dart';
/// Immutable snapshot of the integrations toggles shown on the Settings /// Immutable snapshot of the integrations state shown on the Settings
/// screen. /// screen (Phase 3B: dynamic connection status per provider).
/// ///
/// Native health toggles are gated by the platform actually backing the /// Native health providers are probed through the [IHealthGateway] on every
/// gateway (HealthKit on iOS, Health Connect on Android); the HealthGateway /// connect/disconnect round-trip; the SMART on FHIR enterprise connector
/// connector (cloud partner REST sync) is available everywhere and reports /// reports `notConfigured` until its OAuth2 backend is provisioned.
/// manual-entry fallback on Web/Windows/macOS.
class IntegrationsState { class IntegrationsState {
const IntegrationsState({ const IntegrationsState({
this.appleHealthEnabled = false, this.appleHealthStatus = HealthConnectionStatus.notConfigured,
this.healthConnectEnabled = false, this.healthConnectStatus = HealthConnectionStatus.notConfigured,
this.healthGatewayEnabled = false, this.fhirStatus = HealthConnectionStatus.notConfigured,
this.isRequestingPermissions = false, this.isRequestingPermissions = false,
this.errorMessage, this.errorMessage,
}); });
@ -23,14 +22,15 @@ class IntegrationsState {
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`. /// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
static const Object _keep = Object(); static const Object _keep = Object();
/// Apple HealthKit sync armed (iOS only). /// Apple HealthKit connection status (iOS).
final bool appleHealthEnabled; final HealthConnectionStatus appleHealthStatus;
/// Google Health Connect sync armed (Android only). /// Google Health Connect connection status (Android; federates Samsung
final bool healthConnectEnabled; /// Health and Pixel sensor feeds).
final HealthConnectionStatus healthConnectStatus;
/// HealthGateway (cloud partner / manual-entry fallback) sync armed. /// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
final bool healthGatewayEnabled; final HealthConnectionStatus fhirStatus;
/// `true` while a native permission round-trip is in flight. /// `true` while a native permission round-trip is in flight.
final bool isRequestingPermissions; final bool isRequestingPermissions;
@ -38,17 +38,31 @@ class IntegrationsState {
/// Last permission/persistence failure, surfaced inline. /// Last permission/persistence failure, surfaced inline.
final String? errorMessage; final String? errorMessage;
/// Whether Apple Health is granted on this device.
bool get appleHealthConnected =>
appleHealthStatus == HealthConnectionStatus.connected;
/// Whether Health Connect is granted on this device.
bool get healthConnectConnected =>
healthConnectStatus == HealthConnectionStatus.connected;
/// True when the device offers no native health SDK at all (Web/Windows/
/// macOS fallback driver) — the UI disables native connect buttons.
bool get nativeHealthUnsupported =>
appleHealthStatus == HealthConnectionStatus.unsupportedPlatform &&
healthConnectStatus == HealthConnectionStatus.unsupportedPlatform;
IntegrationsState copyWith({ IntegrationsState copyWith({
bool? appleHealthEnabled, HealthConnectionStatus? appleHealthStatus,
bool? healthConnectEnabled, HealthConnectionStatus? healthConnectStatus,
bool? healthGatewayEnabled, HealthConnectionStatus? fhirStatus,
bool? isRequestingPermissions, bool? isRequestingPermissions,
Object? errorMessage = _keep, Object? errorMessage = _keep,
}) => }) =>
IntegrationsState( IntegrationsState(
appleHealthEnabled: appleHealthEnabled ?? this.appleHealthEnabled, appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
healthConnectEnabled: healthConnectEnabled ?? this.healthConnectEnabled, healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
healthGatewayEnabled: healthGatewayEnabled ?? this.healthGatewayEnabled, fhirStatus: fhirStatus ?? this.fhirStatus,
isRequestingPermissions: isRequestingPermissions:
isRequestingPermissions ?? this.isRequestingPermissions, isRequestingPermissions ?? this.isRequestingPermissions,
errorMessage: identical(errorMessage, _keep) errorMessage: identical(errorMessage, _keep)
@ -57,18 +71,28 @@ class IntegrationsState {
); );
} }
/// Orchestrates the integrations toggles: requests native health /// Orchestrates the integrations section: probes per-provider connection
/// permissions through the cross-platform [IHealthGateway] before arming a /// status through the cross-platform [IHealthGateway], runs native
/// toggle, and degrades gracefully on platforms without native support. /// authorization round-trips on connect, and tears down bindings on
/// disconnect. Degrades gracefully on platforms without native support.
class IntegrationsController extends Notifier<IntegrationsState> { class IntegrationsController extends Notifier<IntegrationsState> {
/// Disposal sentinel: Riverpod 2.x `Ref` has no `mounted` getter, so async
/// gaps guard on this flag instead of touching `state` after teardown.
var _disposed = false;
@override @override
IntegrationsState build() { IntegrationsState build() {
ref.onDispose(() => _disposed = true);
final gateway = ref.read(healthGatewayProvider); final gateway = ref.read(healthGatewayProvider);
// Synchronous first guess from the driver capability; the async probe
// below refines it with the real OS permission state.
final unsupported = gateway.supportsNativeHealth
? HealthConnectionStatus.notConfigured
: HealthConnectionStatus.unsupportedPlatform;
Future<void>.microtask(refreshStatuses);
return IntegrationsState( return IntegrationsState(
// Toggles start disarmed; enabling requests fresh permissions. appleHealthStatus: unsupported,
appleHealthEnabled: false, healthConnectStatus: unsupported,
healthConnectEnabled: false,
healthGatewayEnabled: false,
errorMessage: errorMessage:
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway), gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
); );
@ -82,19 +106,52 @@ class IntegrationsController extends Notifier<IntegrationsState> {
'HealthGateway runs in manual-entry mode.'; 'HealthGateway runs in manual-entry mode.';
} }
/// Arms/disarms the Apple Health toggle. Enabling requests native /// Re-probes the connection status of every provider row.
/// permission; denial reverts the toggle and records a message. Future<void> refreshStatuses() async {
Future<void> setAppleHealthEnabled(bool enabled) => final gateway = ref.read(healthGatewayProvider);
_setNative(enabled: enabled, isApple: true); final apple = await gateway.checkStatus(HealthProviderType.appleHealth);
if (_disposed) return;
final connect = await gateway.checkStatus(HealthProviderType.healthConnect);
if (_disposed) return;
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
if (_disposed) return;
state = state.copyWith(
appleHealthStatus: apple,
healthConnectStatus: connect,
fhirStatus: fhir,
);
}
/// Arms/disarms the Health Connect toggle (same permission contract as /// Runs the native authorization prompt for Apple Health (iOS); a granted
/// Apple Health; the gateway routes to the right SDK per platform). /// round-trip flips the row to `connected`, denial records a message.
Future<void> setHealthConnectEnabled(bool enabled) => Future<void> connectAppleHealth() =>
_setNative(enabled: enabled, isApple: false); _connect(HealthProviderType.appleHealth);
/// Arms/disarms the HealthGateway connector (no native permission needed). /// Runs the native authorization prompt for Health Connect (Android).
void setHealthGatewayEnabled(bool enabled) { Future<void> connectHealthConnect() =>
state = state.copyWith(healthGatewayEnabled: enabled, errorMessage: null); _connect(HealthProviderType.healthConnect);
/// Tears down the binding for [provider] and re-probes its status.
///
/// HealthKit / Health Connect expose no programmatic revocation to
/// third-party apps, so the gateway clears cached state and the user
/// completes revocation in system settings; the re-probe reflects either
/// outcome.
Future<void> disconnectProvider(HealthProviderType provider) async {
final gateway = ref.read(healthGatewayProvider);
try {
await gateway.disconnect(provider);
if (_disposed) return;
_applyStatus(provider, await gateway.checkStatus(provider));
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'health disconnect failed',
);
if (!_disposed) {
state = state.copyWith(errorMessage: 'Could not disconnect: $error');
}
}
} }
/// Clears the transient error banner. /// Clears the transient error banner.
@ -104,44 +161,56 @@ class IntegrationsController extends Notifier<IntegrationsState> {
} }
} }
Future<void> _setNative({required bool enabled, required bool isApple}) async { Future<void> _connect(HealthProviderType provider) async {
if (!enabled) {
state = state.copyWith(
appleHealthEnabled: isApple ? false : state.appleHealthEnabled,
healthConnectEnabled: isApple ? state.healthConnectEnabled : false,
errorMessage: null,
);
return;
}
state = state.copyWith(isRequestingPermissions: true, errorMessage: null); state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
try { try {
final granted = await ref.read(healthGatewayProvider).requestPermissions(); final granted =
if (granted) { await ref.read(healthGatewayProvider).requestAuthorization(provider);
state = state.copyWith( if (_disposed) return;
appleHealthEnabled: isApple ? true : state.appleHealthEnabled, _applyStatus(
healthConnectEnabled: isApple ? state.healthConnectEnabled : true, provider,
isRequestingPermissions: false, granted
? HealthConnectionStatus.connected
: HealthConnectionStatus.permissionDenied,
); );
} else {
state = state.copyWith( state = state.copyWith(
isRequestingPermissions: false, isRequestingPermissions: false,
errorMessage: errorMessage: granted
'Health permissions were denied. Enable them in your device ' ? null
: 'Health permissions were denied. Enable them in your device '
'settings to sync biometrics.', 'settings to sync biometrics.',
); );
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrintStack( debugPrintStack(
stackTrace: stackTrace, stackTrace: stackTrace,
label: 'health permission request failed', label: 'health permission request failed',
); );
if (_disposed) return;
state = state.copyWith( state = state.copyWith(
isRequestingPermissions: false, isRequestingPermissions: false,
errorMessage: 'Could not request health permissions: $error', errorMessage: 'Could not request health permissions: $error',
); );
} }
} }
/// Routes a provider status update to the matching state field (Samsung
/// Health shares the Health Connect row because it federates through it).
void _applyStatus(
HealthProviderType provider,
HealthConnectionStatus status,
) {
switch (provider) {
case HealthProviderType.appleHealth:
state = state.copyWith(appleHealthStatus: status);
case HealthProviderType.healthConnect:
case HealthProviderType.samsungHealth:
state = state.copyWith(healthConnectStatus: status);
case HealthProviderType.enterpriseFhir:
state = state.copyWith(fhirStatus: status);
case HealthProviderType.manualFallback:
break;
}
}
} }
/// Exposes the [IntegrationsController] to the widget tree. /// Exposes the [IntegrationsController] to the widget tree.

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../../core/network/supabase_status.dart'; import '../../../../core/network/supabase_status.dart';
import '../../../../core/platform/health_gateway.dart';
import '../../../auth/presentation/controllers/auth_controller.dart'; import '../../../auth/presentation/controllers/auth_controller.dart';
import '../../../nutrition/presentation/controllers/nutrition_controller.dart'; import '../../../nutrition/presentation/controllers/nutrition_controller.dart';
import '../controllers/integrations_controller.dart'; import '../controllers/integrations_controller.dart';
@ -295,7 +296,13 @@ class _SecuritySection extends StatelessWidget {
} }
} }
/// Integrations: Apple Health / Health Connect / HealthGateway toggles. /// Integrations (Phase 3B): Apple Health / Health Connect (Samsung & Pixel)
/// / SMART on FHIR rows with dynamic status indicators and Connect /
/// Disconnect authorization buttons.
///
/// Providers the current OS cannot host (e.g. HealthKit on Windows/Web)
/// render desktop-safe disabled Connect buttons with an "Unsupported"
/// badge instead of crashing into dead platform channels.
class _IntegrationsSection extends ConsumerWidget { class _IntegrationsSection extends ConsumerWidget {
const _IntegrationsSection({required this.state}); const _IntegrationsSection({required this.state});
@ -304,49 +311,55 @@ class _IntegrationsSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final controller = ref.read(integrationsControllerProvider.notifier);
return _SettingsCard( return _SettingsCard(
title: 'Integrations', title: 'Health Integrations',
icon: Icons.sync_outlined, icon: Icons.cable_outlined,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SwitchListTile( _IntegrationRow(
contentPadding: EdgeInsets.zero, icon: Icons.apple,
secondary: const Icon(Icons.apple), title: 'Apple Health',
title: const Text('Apple Health'), subtitle: 'Weight, active energy & blood glucose (HealthKit)',
subtitle: const Text('Sync weight & active energy (iOS)'), status: state.appleHealthStatus,
value: state.appleHealthEnabled, busy: state.isRequestingPermissions,
onChanged: state.isRequestingPermissions onConnect: state.isRequestingPermissions
? null ? null
: (enabled) => ref : () => controller.connectAppleHealth(),
.read(integrationsControllerProvider.notifier) onDisconnect: state.isRequestingPermissions
.setAppleHealthEnabled(enabled),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.fingerprint),
title: const Text('Health Connect'),
subtitle: const Text('Sync weight & active energy (Android)'),
value: state.healthConnectEnabled,
onChanged: state.isRequestingPermissions
? null ? null
: (enabled) => ref : () => controller
.read(integrationsControllerProvider.notifier) .disconnectProvider(HealthProviderType.appleHealth),
.setHealthConnectEnabled(enabled),
), ),
SwitchListTile( const Divider(),
contentPadding: EdgeInsets.zero, _IntegrationRow(
secondary: const Icon(Icons.cloud_sync_outlined), icon: Icons.fingerprint,
title: const Text('HealthGateway'), title: 'Health Connect (Samsung & Pixel)',
subtitle: const Text( subtitle:
'Cloud partner sync (MyFitnessPal / Withings) with ' 'Federated biometrics from Samsung Health & Pixel sensors',
'manual-entry fallback', status: state.healthConnectStatus,
busy: state.isRequestingPermissions,
onConnect: state.isRequestingPermissions
? null
: () => controller.connectHealthConnect(),
onDisconnect: state.isRequestingPermissions
? null
: () => controller
.disconnectProvider(HealthProviderType.healthConnect),
), ),
value: state.healthGatewayEnabled, const Divider(),
onChanged: (enabled) => ref _IntegrationRow(
.read(integrationsControllerProvider.notifier) icon: Icons.cloud_outlined,
.setHealthGatewayEnabled(enabled), title: 'SMART on FHIR',
subtitle: 'Enterprise lab panels (HbA1c, metabolic markers)',
status: state.fhirStatus,
busy: false,
// The enterprise OAuth2 connector ships in a later phase; the
// row surfaces live status but stays inert until provisioned.
onConnect: null,
onDisconnect: null,
), ),
if (state.isRequestingPermissions) if (state.isRequestingPermissions)
Padding( Padding(
@ -372,6 +385,106 @@ class _IntegrationsSection extends ConsumerWidget {
} }
} }
/// A single provider row: icon, title, subtitle, dynamic status badge, and
/// a Connect / Disconnect authorization button.
class _IntegrationRow extends StatelessWidget {
const _IntegrationRow({
required this.icon,
required this.title,
required this.subtitle,
required this.status,
required this.busy,
required this.onConnect,
required this.onDisconnect,
});
final IconData icon;
final String title;
final String subtitle;
final HealthConnectionStatus status;
final bool busy;
final VoidCallback? onConnect;
final VoidCallback? onDisconnect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final connected = status == HealthConnectionStatus.connected;
final unsupported = status == HealthConnectionStatus.unsupportedPlatform;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child:
Text(title, style: theme.textTheme.titleSmall),
),
const SizedBox(width: 8),
Flexible(
child: _StatusBadge(
label: _statusLabel(status),
ok: connected,
neutral: unsupported,
),
),
],
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.outline),
),
],
),
),
const SizedBox(width: 12),
busy
? const SizedBox(
width: 36,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: connected
? OutlinedButton(
onPressed: onDisconnect,
child: const Text('Disconnect'),
)
: FilledButton.tonal(
// Desktop-safe disabled state: unsupported platforms
// and un-provisioned connectors never fire a dead call.
onPressed: unsupported ? null : onConnect,
child: const Text('Connect'),
),
],
),
);
}
static String _statusLabel(HealthConnectionStatus status) {
switch (status) {
case HealthConnectionStatus.connected:
return 'Connected';
case HealthConnectionStatus.notConfigured:
return 'Not connected';
case HealthConnectionStatus.permissionDenied:
return 'Permission denied';
case HealthConnectionStatus.unsupportedPlatform:
return 'Unsupported';
}
}
}
/// Shared card chrome for a settings section. /// Shared card chrome for a settings section.
class _SettingsCard extends StatelessWidget { class _SettingsCard extends StatelessWidget {
const _SettingsCard({ const _SettingsCard({
@ -415,15 +528,33 @@ class _SettingsCard extends StatelessWidget {
/// Compact colored badge used for capability/status rows. /// Compact colored badge used for capability/status rows.
class _StatusBadge extends StatelessWidget { class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label, required this.ok}); const _StatusBadge({
required this.label,
required this.ok,
this.neutral = false,
});
final String label; final String label;
final bool ok; final bool ok;
/// Renders a grey "not applicable here" tone (e.g. Unsupported on
/// desktop) instead of the attention-grabbing orange.
final bool neutral;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = ok ? Colors.green.shade100 : Colors.orange.shade100; final Color color;
final textColor = ok ? Colors.green.shade800 : Colors.orange.shade900; final Color textColor;
if (neutral) {
color = Colors.grey.shade200;
textColor = Colors.grey.shade700;
} else if (ok) {
color = Colors.green.shade100;
textColor = Colors.green.shade800;
} else {
color = Colors.orange.shade100;
textColor = Colors.orange.shade900;
}
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(

View File

@ -0,0 +1,98 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/platform/health_gateway.dart';
import 'package:glp1_companion/core/platform/health_gateway_fallback.dart';
/// Phase 3B: the Web/Windows/macOS fallback driver must answer every
/// provider query with safe dummy states — never a platform-channel crash.
class _FakeLocalReader implements ILocalBiometricReader {
const _FakeLocalReader({this.weights = const [], this.energies = const []});
final List<HealthMetric> weights;
final List<HealthMetric> energies;
@override
Future<List<HealthMetric>> readWeightRange({
required DateTime start,
required DateTime end,
}) async =>
weights;
@override
Future<List<HealthMetric>> readEnergyRange({
required DateTime start,
required DateTime end,
}) async =>
energies;
}
void main() {
test('checkStatus answers every provider with a safe dummy state', () async {
final gateway = FallbackHealthGateway();
expect(await gateway.checkStatus(HealthProviderType.appleHealth),
HealthConnectionStatus.unsupportedPlatform);
expect(await gateway.checkStatus(HealthProviderType.healthConnect),
HealthConnectionStatus.unsupportedPlatform);
expect(await gateway.checkStatus(HealthProviderType.samsungHealth),
HealthConnectionStatus.unsupportedPlatform);
expect(await gateway.checkStatus(HealthProviderType.enterpriseFhir),
HealthConnectionStatus.notConfigured);
expect(await gateway.checkStatus(HealthProviderType.manualFallback),
HealthConnectionStatus.connected);
});
test('requestAuthorization grants manual entry and refuses native SDKs',
() async {
final gateway = FallbackHealthGateway();
expect(
await gateway.requestAuthorization(HealthProviderType.manualFallback),
isTrue,
);
expect(
await gateway.requestAuthorization(HealthProviderType.healthConnect),
isFalse,
);
expect(
await gateway.requestAuthorization(HealthProviderType.appleHealth),
isFalse,
);
});
test('fetchLatestBiometrics serves the local cache via manualFallback',
() async {
final now = DateTime.now();
final gateway = FallbackHealthGateway(
localBiometricReader: _FakeLocalReader(
weights: [HealthMetric(timestamp: now, value: 172.5, unit: 'lb')],
energies: [
HealthMetric(timestamp: now, value: 210, unit: 'kcal'),
HealthMetric(timestamp: now, value: 90, unit: 'kcal'),
],
),
);
final payload = await gateway.fetchLatestBiometrics();
expect(payload, isNotNull);
expect(payload!.source, HealthProviderType.manualFallback);
expect(payload.weightLbs, 172.5);
expect(payload.activeCaloriesToday, 300);
expect(payload.bloodGlucoseMgDl, isNull);
});
test('fetchLatestBiometrics returns null when no local cache is wired',
() async {
final gateway = FallbackHealthGateway();
expect(await gateway.fetchLatestBiometrics(), isNull);
});
test('disconnect never throws on the fallback driver', () async {
final gateway = FallbackHealthGateway();
await expectLater(
gateway.disconnect(HealthProviderType.healthConnect),
completes,
);
});
}

View File

@ -83,7 +83,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
} }
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect. /// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
class _FakeHealthGateway implements IHealthGateway { class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true; bool nativeSupported = true;
bool granted = true; bool granted = true;
List<HealthMetric> weights = []; List<HealthMetric> weights = [];

View File

@ -0,0 +1,182 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/platform/health_gateway.dart';
import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart';
import 'package:glp1_companion/features/settings/presentation/controllers/integrations_controller.dart';
/// Phase 3B: connection-status orchestration for the Settings integrations
/// section (connect / disconnect round-trips, dynamic status indicators,
/// graceful degradation on unsupported platforms).
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
///
/// Extends the interface so the Phase 3B defaults stay available; the
/// scripted members mirror what the mobile driver does on a real device.
class _ScriptedHealthGateway extends IHealthGateway {
_ScriptedHealthGateway({this.nativeSupported = true});
bool nativeSupported;
bool grantOnRequest = true;
HealthConnectionStatus nativeStatus = HealthConnectionStatus.notConfigured;
final requestedProviders = <HealthProviderType>[];
final disconnectedProviders = <HealthProviderType>[];
@override
bool get supportsNativeHealth => nativeSupported;
@override
NativeHealthPlatform? get nativePlatform =>
nativeSupported ? NativeHealthPlatform.healthConnect : null;
@override
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider,
) async {
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
if (provider == HealthProviderType.enterpriseFhir) {
return HealthConnectionStatus.notConfigured;
}
if (!nativeSupported) return HealthConnectionStatus.unsupportedPlatform;
if (provider == HealthProviderType.healthConnect) return nativeStatus;
return HealthConnectionStatus.unsupportedPlatform;
}
@override
Future<bool> requestAuthorization(HealthProviderType provider) async {
requestedProviders.add(provider);
if (provider == activeProvider && grantOnRequest) {
nativeStatus = HealthConnectionStatus.connected;
}
return grantOnRequest;
}
@override
Future<void> disconnect(HealthProviderType provider) async {
disconnectedProviders.add(provider);
if (provider == activeProvider) {
nativeStatus = HealthConnectionStatus.notConfigured;
}
}
@override
Future<bool> requestPermissions() async => grantOnRequest;
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async =>
const [];
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) async =>
const [];
@override
Future<bool> writeWeight({
required double weightLbs,
required DateTime timestamp,
}) async =>
false;
}
void main() {
late _ScriptedHealthGateway gateway;
late ProviderContainer container;
setUp(() {
gateway = _ScriptedHealthGateway();
container = ProviderContainer(
overrides: [healthGatewayProvider.overrideWithValue(gateway)],
);
addTearDown(container.dispose);
});
IntegrationsState state() => container.read(integrationsControllerProvider);
IntegrationsController controller() =>
container.read(integrationsControllerProvider.notifier);
test(
'unsupported platforms disable native providers and surface the '
'fallback notice', () async {
gateway = _ScriptedHealthGateway(nativeSupported: false);
container = ProviderContainer(
overrides: [healthGatewayProvider.overrideWithValue(gateway)],
);
addTearDown(container.dispose);
await controller().refreshStatuses();
final s = state();
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
expect(s.healthConnectStatus, HealthConnectionStatus.unsupportedPlatform);
expect(s.fhirStatus, HealthConnectionStatus.notConfigured);
expect(s.nativeHealthUnsupported, isTrue);
expect(s.errorMessage, contains('manual-entry mode'));
expect(s.isRequestingPermissions, isFalse);
});
test('initial probe reports notConfigured native providers on mobile',
() async {
await controller().refreshStatuses();
final s = state();
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
expect(s.healthConnectStatus, HealthConnectionStatus.notConfigured);
expect(s.fhirStatus, HealthConnectionStatus.notConfigured);
expect(s.errorMessage, isNull);
});
test('granted authorization flips only the addressed row to connected',
() async {
await controller().refreshStatuses();
await controller().connectHealthConnect();
final s = state();
expect(gateway.requestedProviders, [HealthProviderType.healthConnect]);
expect(s.healthConnectStatus, HealthConnectionStatus.connected);
expect(s.healthConnectConnected, isTrue);
// The Apple Health row is untouched on an Android-backed gateway.
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
expect(s.errorMessage, isNull);
expect(s.isRequestingPermissions, isFalse);
});
test('denied authorization records permissionDenied and an error message',
() async {
gateway.grantOnRequest = false;
await controller().connectHealthConnect();
final s = state();
expect(s.healthConnectStatus, HealthConnectionStatus.permissionDenied);
expect(s.healthConnectConnected, isFalse);
expect(s.errorMessage, contains('denied'));
expect(s.isRequestingPermissions, isFalse);
});
test('disconnect tears down the binding and re-probes the status', () async {
await controller().connectHealthConnect();
expect(state().healthConnectStatus, HealthConnectionStatus.connected);
await controller().disconnectProvider(HealthProviderType.healthConnect);
expect(gateway.disconnectedProviders, [HealthProviderType.healthConnect]);
expect(state().healthConnectStatus, HealthConnectionStatus.notConfigured);
});
test('clearError resets the transient banner', () async {
gateway.grantOnRequest = false;
await controller().connectHealthConnect();
expect(state().errorMessage, isNotNull);
controller().clearError();
expect(state().errorMessage, isNull);
});
}

View File

@ -0,0 +1,172 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/core/platform/health_gateway.dart';
import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart';
import 'package:glp1_companion/features/settings/presentation/screens/settings_screen.dart';
/// Phase 3B: the Settings screen must render the Health Integrations section
/// with desktop-safe disabled states when native mobile providers are
/// unavailable (Windows / Web fallback driver), and run a full connect /
/// disconnect round trip through the UI when they are.
/// Fallback driver stand-in: no native SDK anywhere.
class _UnsupportedHealthGateway extends IHealthGateway {
@override
bool get supportsNativeHealth => false;
@override
NativeHealthPlatform? get nativePlatform => null;
@override
Future<bool> requestPermissions() async => false;
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async =>
const [];
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) async =>
const [];
@override
Future<bool> writeWeight({
required double weightLbs,
required DateTime timestamp,
}) async =>
false;
}
/// Android-backed stand-in: Health Connect answers status and grants on
/// request; Apple Health stays unsupported.
class _HealthConnectGateway extends _UnsupportedHealthGateway {
HealthConnectionStatus status = HealthConnectionStatus.notConfigured;
@override
bool get supportsNativeHealth => true;
@override
NativeHealthPlatform? get nativePlatform => NativeHealthPlatform.healthConnect;
@override
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider,
) async {
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
if (provider == HealthProviderType.enterpriseFhir) {
return HealthConnectionStatus.notConfigured;
}
if (provider == HealthProviderType.healthConnect) return status;
return HealthConnectionStatus.unsupportedPlatform;
}
@override
Future<bool> requestAuthorization(HealthProviderType provider) async {
if (provider == HealthProviderType.healthConnect) {
status = HealthConnectionStatus.connected;
return true;
}
return false;
}
@override
Future<void> disconnect(HealthProviderType provider) async {
if (provider == HealthProviderType.healthConnect) {
status = HealthConnectionStatus.notConfigured;
}
}
}
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() => database.close());
Widget buildScreen(IHealthGateway gateway) => ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(database),
healthGatewayProvider.overrideWithValue(gateway),
],
child: const MaterialApp(home: SettingsScreen()),
);
// A tall phone-like viewport keeps every settings card on-screen (the
// ListView builds lazily beyond its cache extent).
void useTallViewport(WidgetTester tester) {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
}
testWidgets(
'desktop fallback renders integrations with disabled Connect buttons '
'and Unsupported badges', (tester) async {
useTallViewport(tester);
await tester.pumpWidget(buildScreen(_UnsupportedHealthGateway()));
await tester.pumpAndSettle();
expect(find.text('Health Integrations'), findsOneWidget);
expect(find.text('Apple Health'), findsOneWidget);
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
expect(find.text('SMART on FHIR'), findsOneWidget);
// Native providers are unsupported on desktop; the FHIR connector is
// not provisioned yet — every Connect affordance is disabled.
expect(find.text('Unsupported'), findsNWidgets(2));
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
expect(connectButtons, findsNWidgets(3));
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
expect(button.onPressed, isNull);
}
// Encrypted storage card and the Supabase sign-out action are present.
expect(find.text('SQLCipher AES-256 active'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Sign Out'), findsOneWidget);
});
testWidgets(
'mobile gateway runs the connect / disconnect round trip with live '
'status badges', (tester) async {
useTallViewport(tester);
final gateway = _HealthConnectGateway();
await tester.pumpWidget(buildScreen(gateway));
await tester.pumpAndSettle();
// Health Connect row is available with an enabled Connect button (the
// FHIR row also reads "Not connected" until its connector ships).
expect(find.text('Not connected'), findsNWidgets(2));
final connectFinder = find.widgetWithText(FilledButton, 'Connect');
final allConnect = connectFinder.evaluate().toList();
final enabledIndex = allConnect
.indexWhere((element) => (element.widget as FilledButton).onPressed != null);
expect(enabledIndex, greaterThanOrEqualTo(0));
await tester.tap(connectFinder.at(enabledIndex));
await tester.pumpAndSettle();
// Connected: badge flips and the button becomes Disconnect.
expect(find.text('Connected'), findsOneWidget);
final disconnect = find.widgetWithText(OutlinedButton, 'Disconnect');
expect(disconnect, findsOneWidget);
await tester.tap(disconnect);
await tester.pumpAndSettle();
expect(find.text('Not connected'), findsNWidgets(2));
expect(find.text('Connected'), findsNothing);
});
}

View File

@ -257,7 +257,7 @@ void main() {
expect(find.text('Identity'), findsOneWidget); expect(find.text('Identity'), findsOneWidget);
expect(find.text('Targets'), findsOneWidget); expect(find.text('Targets'), findsOneWidget);
expect(find.text('Security & Storage'), findsOneWidget); expect(find.text('Security & Storage'), findsOneWidget);
expect(find.text('Integrations'), findsOneWidget); expect(find.text('Health Integrations'), findsOneWidget);
await unmountAndFlushTimers(tester); await unmountAndFlushTimers(tester);
}); });