From ddcdefa596b953dd6869f59b715b582e3358c328 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 17 Oct 2026 12:01:59 -0400 Subject: [PATCH] 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. --- BUILD_STATE.md | 17 ++ android/app/src/main/AndroidManifest.xml | 22 +- docs/architecture.md | 130 +++++++++++ ios/Runner/Info.plist | 4 + lib/core/platform/health_gateway.dart | 150 ++++++++++++- .../platform/health_gateway_fallback.dart | 7 +- lib/core/platform/health_gateway_mobile.dart | 112 +++++++++- .../controllers/integrations_controller.dart | 197 +++++++++++------ .../presentation/screens/settings_screen.dart | 209 ++++++++++++++---- .../fallback_health_gateway_test.dart | 98 ++++++++ .../biometric_controller_test.dart | 2 +- .../integrations_controller_test.dart | 182 +++++++++++++++ .../settings/settings_screen_test.dart | 172 ++++++++++++++ test/integration/app_navigation_test.dart | 2 +- 14 files changed, 1193 insertions(+), 111 deletions(-) create mode 100644 test/core/platform/fallback_health_gateway_test.dart create mode 100644 test/features/settings/integrations_controller_test.dart create mode 100644 test/features/settings/settings_screen_test.dart 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); });