diff --git a/BUILD_STATE.md b/BUILD_STATE.md
index f6d360f..4b9c285 100644
--- a/BUILD_STATE.md
+++ b/BUILD_STATE.md
@@ -3,11 +3,13 @@
## Active Target
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
- **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
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
- [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 5: Pantry Delta Engine & Auto-Shopping Generator
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
@@ -85,6 +87,21 @@
## 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.
+## 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 ``, 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)
- [x] `dart analyze lib test` (Zero issues)
- [x] `flutter test` (all passing — 125/125)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5432ea4..26fc02a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,11 +5,13 @@
+ active energy burned, blood glucose). Health Connect federates
+ Samsung Health and Pixel sensor feeds through this runtime. -->
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/architecture.md b/docs/architecture.md
index 75dc5bb..d07a935 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -86,6 +86,136 @@ Within each feature module in `lib/features//`, separation of conc
| **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 checkStatus(HealthProviderType provider);
+
+ /// Triggers native OS permission request prompt.
+ Future requestAuthorization(HealthProviderType provider);
+
+ /// Queries biometrics aggregated across the current calendar day.
+ Future fetchLatestBiometrics();
+
+ /// Revokes active session bindings or tears down local connections.
+ Future 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+##### iOS (`ios/Runner/Info.plist`)
+```xml
+NSHealthShareUsageDescription
+GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.
+NSHealthUpdateUsageDescription
+GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.
+```
+
+#### 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
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 6e5eb72..4f50da2 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -24,6 +24,10 @@
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ NSHealthShareUsageDescription
+ GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.
+ NSHealthUpdateUsageDescription
+ GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.
LSRequiresIPhoneOS
UIApplicationSceneManifest
diff --git a/lib/core/platform/health_gateway.dart b/lib/core/platform/health_gateway.dart
index dac4171..e670c63 100644
--- a/lib/core/platform/health_gateway.dart
+++ b/lib/core/platform/health_gateway.dart
@@ -1,3 +1,5 @@
+import 'package:flutter/foundation.dart';
+
import 'health_gateway_fallback.dart'
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
@@ -14,10 +16,69 @@ enum NativeHealthPlatform {
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.
///
-/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
-/// normalization (kg → lb, joules → kcal) happens inside the gateway so
+/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
+/// normalization (kg → lb, joules → kcal) happens inside the gateway so
/// consumers only ever deal in pounds and Calories.
class HealthMetric {
const HealthMetric({
@@ -103,6 +164,91 @@ abstract class IHealthGateway {
required double weightLbs,
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 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 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 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 disconnect(HealthProviderType provider) async {
+ debugPrint(
+ 'HealthGateway: disconnect($provider) ignored - revoke access in the '
+ 'system health settings.',
+ );
+ }
}
/// Platform-appropriate factory: native `health`-backed gateway on
diff --git a/lib/core/platform/health_gateway_fallback.dart b/lib/core/platform/health_gateway_fallback.dart
index 0e0822d..94fa5cb 100644
--- a/lib/core/platform/health_gateway_fallback.dart
+++ b/lib/core/platform/health_gateway_fallback.dart
@@ -13,13 +13,18 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
/// 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
/// never crash: permissions are refused, writes are rejected, and reads are
/// served from the injected [ILocalBiometricReader] (the encrypted drift
/// cache of locally logged biometrics) — or from nothing when no reader was
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
/// open the manual-entry dialog.
-class FallbackHealthGateway implements IHealthGateway {
+class FallbackHealthGateway extends IHealthGateway {
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
: _localReader = localBiometricReader {
// Debug-only diagnostic: makes the active driver obvious while
diff --git a/lib/core/platform/health_gateway_mobile.dart b/lib/core/platform/health_gateway_mobile.dart
index 7ba09c1..5d87907 100644
--- a/lib/core/platform/health_gateway_mobile.dart
+++ b/lib/core/platform/health_gateway_mobile.dart
@@ -16,6 +16,9 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// `package:health`-backed gateway for Android (Health Connect) and iOS
/// (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
/// environments):
///
@@ -31,7 +34,7 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// 4. **Defensive:** every native call is wrapped in `try/catch` so missing
/// HealthKit entitlements or hardware quirks can never surface as
/// unhandled exceptions.
-class MobileHealthGateway implements IHealthGateway {
+class MobileHealthGateway extends IHealthGateway {
final Health _health = Health();
Future? _configuration;
@@ -40,12 +43,15 @@ class MobileHealthGateway implements IHealthGateway {
/// Read set requested from Health Connect (Android), mirroring 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 _androidReadTypes = [
HealthDataType.WEIGHT,
HealthDataType.STEPS,
HealthDataType.TOTAL_CALORIES_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED,
+ HealthDataType.BLOOD_GLUCOSE,
];
/// Read set requested from HealthKit (iOS); requires the HealthKit
@@ -54,6 +60,7 @@ class MobileHealthGateway implements IHealthGateway {
static const List _iosReadTypes = [
HealthDataType.WEIGHT,
HealthDataType.ACTIVE_ENERGY_BURNED,
+ HealthDataType.BLOOD_GLUCOSE,
];
@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 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 requestAuthorization(HealthProviderType provider) async {
+ if (provider == HealthProviderType.manualFallback) return true;
+ if (!_providerMatches(provider)) return false;
+ return requestPermissions();
+ }
+
+ @override
+ Future 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 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
Future> fetchWeightHistory({
required DateTime start,
@@ -274,4 +377,9 @@ class MobileHealthGateway implements IHealthGateway {
/// Active energy normally arrives in kilocalories; normalize joules too.
double _energyInKcal(double raw, HealthDataUnit unit) =>
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;
}
diff --git a/lib/features/settings/presentation/controllers/integrations_controller.dart b/lib/features/settings/presentation/controllers/integrations_controller.dart
index 6501ea3..57a024e 100644
--- a/lib/features/settings/presentation/controllers/integrations_controller.dart
+++ b/lib/features/settings/presentation/controllers/integrations_controller.dart
@@ -4,18 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/platform/health_gateway.dart';
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
-/// Immutable snapshot of the integrations toggles shown on the Settings
-/// screen.
+/// Immutable snapshot of the integrations state shown on the Settings
+/// screen (Phase 3B: dynamic connection status per provider).
///
-/// Native health toggles are gated by the platform actually backing the
-/// gateway (HealthKit on iOS, Health Connect on Android); the HealthGateway
-/// connector (cloud partner REST sync) is available everywhere and reports
-/// manual-entry fallback on Web/Windows/macOS.
+/// Native health providers are probed through the [IHealthGateway] on every
+/// connect/disconnect round-trip; the SMART on FHIR enterprise connector
+/// reports `notConfigured` until its OAuth2 backend is provisioned.
class IntegrationsState {
const IntegrationsState({
- this.appleHealthEnabled = false,
- this.healthConnectEnabled = false,
- this.healthGatewayEnabled = false,
+ this.appleHealthStatus = HealthConnectionStatus.notConfigured,
+ this.healthConnectStatus = HealthConnectionStatus.notConfigured,
+ this.fhirStatus = HealthConnectionStatus.notConfigured,
this.isRequestingPermissions = false,
this.errorMessage,
});
@@ -23,14 +22,15 @@ class IntegrationsState {
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
static const Object _keep = Object();
- /// Apple HealthKit sync armed (iOS only).
- final bool appleHealthEnabled;
+ /// Apple HealthKit connection status (iOS).
+ final HealthConnectionStatus appleHealthStatus;
- /// Google Health Connect sync armed (Android only).
- final bool healthConnectEnabled;
+ /// Google Health Connect connection status (Android; federates Samsung
+ /// Health and Pixel sensor feeds).
+ final HealthConnectionStatus healthConnectStatus;
- /// HealthGateway (cloud partner / manual-entry fallback) sync armed.
- final bool healthGatewayEnabled;
+ /// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
+ final HealthConnectionStatus fhirStatus;
/// `true` while a native permission round-trip is in flight.
final bool isRequestingPermissions;
@@ -38,17 +38,31 @@ class IntegrationsState {
/// Last permission/persistence failure, surfaced inline.
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({
- bool? appleHealthEnabled,
- bool? healthConnectEnabled,
- bool? healthGatewayEnabled,
+ HealthConnectionStatus? appleHealthStatus,
+ HealthConnectionStatus? healthConnectStatus,
+ HealthConnectionStatus? fhirStatus,
bool? isRequestingPermissions,
Object? errorMessage = _keep,
}) =>
IntegrationsState(
- appleHealthEnabled: appleHealthEnabled ?? this.appleHealthEnabled,
- healthConnectEnabled: healthConnectEnabled ?? this.healthConnectEnabled,
- healthGatewayEnabled: healthGatewayEnabled ?? this.healthGatewayEnabled,
+ appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
+ healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
+ fhirStatus: fhirStatus ?? this.fhirStatus,
isRequestingPermissions:
isRequestingPermissions ?? this.isRequestingPermissions,
errorMessage: identical(errorMessage, _keep)
@@ -57,18 +71,28 @@ class IntegrationsState {
);
}
-/// Orchestrates the integrations toggles: requests native health
-/// permissions through the cross-platform [IHealthGateway] before arming a
-/// toggle, and degrades gracefully on platforms without native support.
+/// Orchestrates the integrations section: probes per-provider connection
+/// status through the cross-platform [IHealthGateway], runs native
+/// authorization round-trips on connect, and tears down bindings on
+/// disconnect. Degrades gracefully on platforms without native support.
class IntegrationsController extends Notifier {
+ /// 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
IntegrationsState build() {
+ ref.onDispose(() => _disposed = true);
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.microtask(refreshStatuses);
return IntegrationsState(
- // Toggles start disarmed; enabling requests fresh permissions.
- appleHealthEnabled: false,
- healthConnectEnabled: false,
- healthGatewayEnabled: false,
+ appleHealthStatus: unsupported,
+ healthConnectStatus: unsupported,
errorMessage:
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
);
@@ -82,19 +106,52 @@ class IntegrationsController extends Notifier {
'HealthGateway runs in manual-entry mode.';
}
- /// Arms/disarms the Apple Health toggle. Enabling requests native
- /// permission; denial reverts the toggle and records a message.
- Future setAppleHealthEnabled(bool enabled) =>
- _setNative(enabled: enabled, isApple: true);
+ /// Re-probes the connection status of every provider row.
+ Future refreshStatuses() async {
+ final gateway = ref.read(healthGatewayProvider);
+ 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
- /// Apple Health; the gateway routes to the right SDK per platform).
- Future setHealthConnectEnabled(bool enabled) =>
- _setNative(enabled: enabled, isApple: false);
+ /// Runs the native authorization prompt for Apple Health (iOS); a granted
+ /// round-trip flips the row to `connected`, denial records a message.
+ Future connectAppleHealth() =>
+ _connect(HealthProviderType.appleHealth);
- /// Arms/disarms the HealthGateway connector (no native permission needed).
- void setHealthGatewayEnabled(bool enabled) {
- state = state.copyWith(healthGatewayEnabled: enabled, errorMessage: null);
+ /// Runs the native authorization prompt for Health Connect (Android).
+ Future connectHealthConnect() =>
+ _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 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.
@@ -104,44 +161,56 @@ class IntegrationsController extends Notifier {
}
}
- Future _setNative({required bool enabled, required bool isApple}) async {
- if (!enabled) {
- state = state.copyWith(
- appleHealthEnabled: isApple ? false : state.appleHealthEnabled,
- healthConnectEnabled: isApple ? state.healthConnectEnabled : false,
- errorMessage: null,
- );
- return;
- }
-
+ Future _connect(HealthProviderType provider) async {
state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
try {
- final granted = await ref.read(healthGatewayProvider).requestPermissions();
- if (granted) {
- state = state.copyWith(
- appleHealthEnabled: isApple ? true : state.appleHealthEnabled,
- healthConnectEnabled: isApple ? state.healthConnectEnabled : true,
- isRequestingPermissions: false,
- );
- } else {
- state = state.copyWith(
- isRequestingPermissions: false,
- errorMessage:
- 'Health permissions were denied. Enable them in your device '
- 'settings to sync biometrics.',
- );
- }
+ final granted =
+ await ref.read(healthGatewayProvider).requestAuthorization(provider);
+ if (_disposed) return;
+ _applyStatus(
+ provider,
+ granted
+ ? HealthConnectionStatus.connected
+ : HealthConnectionStatus.permissionDenied,
+ );
+ state = state.copyWith(
+ isRequestingPermissions: false,
+ errorMessage: granted
+ ? null
+ : 'Health permissions were denied. Enable them in your device '
+ 'settings to sync biometrics.',
+ );
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'health permission request failed',
);
+ if (_disposed) return;
state = state.copyWith(
isRequestingPermissions: false,
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.
diff --git a/lib/features/settings/presentation/screens/settings_screen.dart b/lib/features/settings/presentation/screens/settings_screen.dart
index 4b7772f..01fc001 100644
--- a/lib/features/settings/presentation/screens/settings_screen.dart
+++ b/lib/features/settings/presentation/screens/settings_screen.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../../core/network/supabase_status.dart';
+import '../../../../core/platform/health_gateway.dart';
import '../../../auth/presentation/controllers/auth_controller.dart';
import '../../../nutrition/presentation/controllers/nutrition_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 {
const _IntegrationsSection({required this.state});
@@ -304,49 +311,55 @@ class _IntegrationsSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
+ final controller = ref.read(integrationsControllerProvider.notifier);
return _SettingsCard(
- title: 'Integrations',
- icon: Icons.sync_outlined,
+ title: 'Health Integrations',
+ icon: Icons.cable_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SwitchListTile(
- contentPadding: EdgeInsets.zero,
- secondary: const Icon(Icons.apple),
- title: const Text('Apple Health'),
- subtitle: const Text('Sync weight & active energy (iOS)'),
- value: state.appleHealthEnabled,
- onChanged: state.isRequestingPermissions
+ _IntegrationRow(
+ icon: Icons.apple,
+ title: 'Apple Health',
+ subtitle: 'Weight, active energy & blood glucose (HealthKit)',
+ status: state.appleHealthStatus,
+ busy: state.isRequestingPermissions,
+ onConnect: state.isRequestingPermissions
? null
- : (enabled) => ref
- .read(integrationsControllerProvider.notifier)
- .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
+ : () => controller.connectAppleHealth(),
+ onDisconnect: state.isRequestingPermissions
? null
- : (enabled) => ref
- .read(integrationsControllerProvider.notifier)
- .setHealthConnectEnabled(enabled),
+ : () => controller
+ .disconnectProvider(HealthProviderType.appleHealth),
),
- SwitchListTile(
- contentPadding: EdgeInsets.zero,
- secondary: const Icon(Icons.cloud_sync_outlined),
- title: const Text('HealthGateway'),
- subtitle: const Text(
- 'Cloud partner sync (MyFitnessPal / Withings) with '
- 'manual-entry fallback',
- ),
- value: state.healthGatewayEnabled,
- onChanged: (enabled) => ref
- .read(integrationsControllerProvider.notifier)
- .setHealthGatewayEnabled(enabled),
+ const Divider(),
+ _IntegrationRow(
+ icon: Icons.fingerprint,
+ title: 'Health Connect (Samsung & Pixel)',
+ subtitle:
+ 'Federated biometrics from Samsung Health & Pixel sensors',
+ status: state.healthConnectStatus,
+ busy: state.isRequestingPermissions,
+ onConnect: state.isRequestingPermissions
+ ? null
+ : () => controller.connectHealthConnect(),
+ onDisconnect: state.isRequestingPermissions
+ ? null
+ : () => controller
+ .disconnectProvider(HealthProviderType.healthConnect),
+ ),
+ const Divider(),
+ _IntegrationRow(
+ icon: Icons.cloud_outlined,
+ 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)
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.
class _SettingsCard extends StatelessWidget {
const _SettingsCard({
@@ -415,15 +528,33 @@ class _SettingsCard extends StatelessWidget {
/// Compact colored badge used for capability/status rows.
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 bool ok;
+ /// Renders a grey "not applicable here" tone (e.g. Unsupported on
+ /// desktop) instead of the attention-grabbing orange.
+ final bool neutral;
+
@override
Widget build(BuildContext context) {
- final color = ok ? Colors.green.shade100 : Colors.orange.shade100;
- final textColor = ok ? Colors.green.shade800 : Colors.orange.shade900;
+ final Color color;
+ 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(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
diff --git a/test/core/platform/fallback_health_gateway_test.dart b/test/core/platform/fallback_health_gateway_test.dart
new file mode 100644
index 0000000..a1c25b8
--- /dev/null
+++ b/test/core/platform/fallback_health_gateway_test.dart
@@ -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 weights;
+ final List energies;
+
+ @override
+ Future> readWeightRange({
+ required DateTime start,
+ required DateTime end,
+ }) async =>
+ weights;
+
+ @override
+ Future> 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,
+ );
+ });
+}
diff --git a/test/features/health_sync/presentation/biometric_controller_test.dart b/test/features/health_sync/presentation/biometric_controller_test.dart
index af5939a..38f36cd 100644
--- a/test/features/health_sync/presentation/biometric_controller_test.dart
+++ b/test/features/health_sync/presentation/biometric_controller_test.dart
@@ -83,7 +83,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
}
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
-class _FakeHealthGateway implements IHealthGateway {
+class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true;
bool granted = true;
List weights = [];
diff --git a/test/features/settings/integrations_controller_test.dart b/test/features/settings/integrations_controller_test.dart
new file mode 100644
index 0000000..3ac4fcb
--- /dev/null
+++ b/test/features/settings/integrations_controller_test.dart
@@ -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 = [];
+ final disconnectedProviders = [];
+
+ @override
+ bool get supportsNativeHealth => nativeSupported;
+
+ @override
+ NativeHealthPlatform? get nativePlatform =>
+ nativeSupported ? NativeHealthPlatform.healthConnect : null;
+
+ @override
+ Future 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 requestAuthorization(HealthProviderType provider) async {
+ requestedProviders.add(provider);
+ if (provider == activeProvider && grantOnRequest) {
+ nativeStatus = HealthConnectionStatus.connected;
+ }
+ return grantOnRequest;
+ }
+
+ @override
+ Future disconnect(HealthProviderType provider) async {
+ disconnectedProviders.add(provider);
+ if (provider == activeProvider) {
+ nativeStatus = HealthConnectionStatus.notConfigured;
+ }
+ }
+
+ @override
+ Future requestPermissions() async => grantOnRequest;
+
+ @override
+ Future> fetchWeightHistory({
+ required DateTime start,
+ required DateTime end,
+ }) async =>
+ const [];
+
+ @override
+ Future> fetchActiveEnergyBurned({
+ required DateTime start,
+ required DateTime end,
+ }) async =>
+ const [];
+
+ @override
+ Future 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);
+ });
+}
diff --git a/test/features/settings/settings_screen_test.dart b/test/features/settings/settings_screen_test.dart
new file mode 100644
index 0000000..0facf6a
--- /dev/null
+++ b/test/features/settings/settings_screen_test.dart
@@ -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 requestPermissions() async => false;
+
+ @override
+ Future> fetchWeightHistory({
+ required DateTime start,
+ required DateTime end,
+ }) async =>
+ const [];
+
+ @override
+ Future> fetchActiveEnergyBurned({
+ required DateTime start,
+ required DateTime end,
+ }) async =>
+ const [];
+
+ @override
+ Future 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 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 requestAuthorization(HealthProviderType provider) async {
+ if (provider == HealthProviderType.healthConnect) {
+ status = HealthConnectionStatus.connected;
+ return true;
+ }
+ return false;
+ }
+
+ @override
+ Future 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(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);
+ });
+}
diff --git a/test/integration/app_navigation_test.dart b/test/integration/app_navigation_test.dart
index 9994e73..5ac130a 100644
--- a/test/integration/app_navigation_test.dart
+++ b/test/integration/app_navigation_test.dart
@@ -257,7 +257,7 @@ void main() {
expect(find.text('Identity'), findsOneWidget);
expect(find.text('Targets'), findsOneWidget);
expect(find.text('Security & Storage'), findsOneWidget);
- expect(find.text('Integrations'), findsOneWidget);
+ expect(find.text('Health Integrations'), findsOneWidget);
await unmountAndFlushTimers(tester);
});