feat(platform,settings,tests): complete Phase 3B platform health integrations and HealthGateway end-to-end
Implements architecture section 4.7: IHealthGateway now exposes the Phase 3B contract (HealthProviderType, HealthConnectionStatus, HealthDataPayload, checkStatus/requestAuthorization/fetchLatestBiometrics/disconnect) with defaults derived from the existing primitives. Drivers and test fakes switch from implements to extends so Dart inherits the default bodies. - Mobile driver: BLOOD_GLUCOSE added to iOS/Android read sets (mmol/L to mg/dL normalization); checkStatus probes hasPermissions plus Health Connect SDK readiness; samsungHealth aliases the Health Connect binding on Android (federated Samsung/Pixel feeds); disconnect clears the cached SDK probe. - Fallback driver: safe dummy states on Windows/Web/macOS (manualFallback connected, enterpriseFhir notConfigured, native unsupportedPlatform); fetchLatestBiometrics serves the encrypted drift cache; zero platform-channel calls. - Settings: IntegrationsController rewritten from toggles to status orchestration (refreshStatuses, connect/disconnect round-trips, denial to permissionDenied plus snackbar); SettingsScreen gains a Health Integrations card with Apple Health, Health Connect (Samsung & Pixel), and SMART on FHIR rows showing live status badges and Connect/Disconnect buttons with desktop-safe disabled states. - Manifests: Android adds READ_BLOOD_GLUCOSE, healthdata package query, ACTION_SHOW_PERMISSIONS_RATIONALE intent, and ViewPermissionUsageActivity alias; iOS adds NSHealthShareUsageDescription and NSHealthUpdateUsageDescription. - Tests: +13 (fallback dummy-state matrix, integrations controller transitions, Settings desktop disabled-states and mobile connect/disconnect round trip); existing fake switched to extends; navigation test updated for the renamed card title. Verified: build_runner 191 outputs; flutter analyze clean; flutter test 148/148; flutter build windows debug succeeded.
This commit is contained in:
parent
2f818ab850
commit
ddcdefa596
|
|
@ -3,11 +3,13 @@
|
||||||
## Active Target
|
## Active Target
|
||||||
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
||||||
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
|
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
|
||||||
|
- **Latest Addendum:** Phase 3B retrofit — Platform Health Integrations & HealthGateway end-to-end (2026-10-17)
|
||||||
|
|
||||||
## Phase Checklist
|
## Phase Checklist
|
||||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||||
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
||||||
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
||||||
|
- [x] Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements)
|
||||||
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||||
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||||
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||||
|
|
@ -85,6 +87,21 @@
|
||||||
## Post-Phase-7 Additions
|
## Post-Phase-7 Additions
|
||||||
- [x] App icon branding (2026-10-16): replaced all stock Flutter icons with the "cg" leaf monogram brand mark. Master asset `assets/icon/app_icon.jpg` (2048×2048); `assets/icon/generate_icons.py` (Python/Pillow, Lanczos) regenerates every platform size idempotently: web (`favicon.png` 32, `icons/Icon-{192,512}.png` with feathered circular alpha, `icons/Icon-maskable-{192,512}.png` at 80% safe-zone over brand sage `#D4E4C7`), Android (legacy `mipmap-*/ic_launcher.png` 48–192 px + new adaptive icons: `mipmap-anydpi-v26/ic_launcher{,_round}.xml`, `drawable/ic_launcher_background.xml` sage vector, `drawable-*/ic_launcher_foreground.png` 66/108 dp safe circle), iOS (all 15 `AppIcon.appiconset` PNGs — opaque RGB, no alpha, per App Store validation), macOS (`app_icon_{16..1024}.png`), Windows (`app_icon.ico` multi-res 16–256 px). Web polish: `manifest.json` name/description/theme (`#375A53` teal, `#D4E4C7` sage) and `index.html` title/description/`theme-color` updated from Flutter placeholders. Verified: PIL dimension/mode/alpha assertions on every output + XML well-formedness + ICO frame enumeration; `flutter build web --no-tree-shake-icons` and `flutter build apk --debug` package the new assets.
|
- [x] App icon branding (2026-10-16): replaced all stock Flutter icons with the "cg" leaf monogram brand mark. Master asset `assets/icon/app_icon.jpg` (2048×2048); `assets/icon/generate_icons.py` (Python/Pillow, Lanczos) regenerates every platform size idempotently: web (`favicon.png` 32, `icons/Icon-{192,512}.png` with feathered circular alpha, `icons/Icon-maskable-{192,512}.png` at 80% safe-zone over brand sage `#D4E4C7`), Android (legacy `mipmap-*/ic_launcher.png` 48–192 px + new adaptive icons: `mipmap-anydpi-v26/ic_launcher{,_round}.xml`, `drawable/ic_launcher_background.xml` sage vector, `drawable-*/ic_launcher_foreground.png` 66/108 dp safe circle), iOS (all 15 `AppIcon.appiconset` PNGs — opaque RGB, no alpha, per App Store validation), macOS (`app_icon_{16..1024}.png`), Windows (`app_icon.ico` multi-res 16–256 px). Web polish: `manifest.json` name/description/theme (`#375A53` teal, `#D4E4C7` sage) and `index.html` title/description/`theme-color` updated from Flutter placeholders. Verified: PIL dimension/mode/alpha assertions on every output + XML well-formedness + ICO frame enumeration; `flutter build web --no-tree-shake-icons` and `flutter build apk --debug` package the new assets.
|
||||||
|
|
||||||
|
## Phase 3B Deliverables (2026-10-17, architecture §4.7 retrofit)
|
||||||
|
- [x] Platform contract (`lib/core/platform/health_gateway.dart`): Phase 3B surface added — `HealthProviderType` (appleHealth, healthConnect, samsungHealth, enterpriseFhir, manualFallback), `HealthConnectionStatus` (notConfigured, permissionDenied, connected, unsupportedPlatform), `HealthDataPayload` (weightLbs / activeCaloriesToday / bloodGlucoseMgDl / timestamp / source), and `IHealthGateway.checkStatus` / `requestAuthorization` / `fetchLatestBiometrics` / `disconnect` with defaults derived from the existing primitives. NOTE for future implementors: Dart `implements` never inherits method bodies — drivers and test fakes must `extends IHealthGateway` (or reimplement the members) to pick up the defaults.
|
||||||
|
- [x] Platform (mobile driver): `BLOOD_GLUCOSE` added to the iOS/Android `package:health` read sets (mmol/L → mg/dL normalization ×18.0182); `checkStatus` probes `hasPermissions` + Health Connect SDK readiness before answering; `samsungHealth` aliases the Health Connect binding on Android (federated Samsung Health + Pixel sensor feeds); `disconnect` clears the cached SDK probe (neither HealthKit nor Health Connect exposes programmatic revocation — OS-level revoke stays in system settings); `fetchLatestBiometrics` aggregates today's weight (latest), active energy (sum), and blood glucose (latest).
|
||||||
|
- [x] Platform (fallback driver): inherits the Phase 3B defaults — manualFallback → `connected`, enterpriseFhir → `notConfigured`, native providers → `unsupportedPlatform`; `fetchLatestBiometrics` composes the encrypted drift cache via `ILocalBiometricReader` with source `manualFallback`; zero platform-channel calls on Web/Windows/macOS (safe dummy states, never a crash).
|
||||||
|
- [x] Presentation (`IntegrationsController` rewrite): status-based orchestration replacing the old toggles — `refreshStatuses()` probes all three providers on build (disposal-flag guarded; Riverpod 2.x `Ref` has no `mounted`), `connectAppleHealth`/`connectHealthConnect` run `requestAuthorization` round-trips (denial → `permissionDenied` + inline snackbar), `disconnectProvider` tears down via the gateway and re-probes.
|
||||||
|
- [x] Presentation (`SettingsScreen`): `Health Integrations` card with Apple Health, Health Connect (Samsung & Pixel), and SMART on FHIR rows — live status badges (Connected / Not connected / Permission denied / Unsupported) and Connect/Disconnect authorization buttons; desktop/web render disabled Connect buttons + neutral grey "Unsupported" badges (desktop-safe states, no dead platform channels); the FHIR row stays inert until its OAuth2 connector is provisioned. Identity (Supabase sign-out), Targets, and SQLCipher status cards unchanged; `/settings` route wiring unchanged.
|
||||||
|
- [x] Manifests: Android — `android.permission.health.READ_BLOOD_GLUCOSE` added to the Health Connect permission set, `com.google.android.apps.healthdata` package query + `ACTION_SHOW_PERMISSIONS_RATIONALE` intent in `<queries>`, and `ViewPermissionUsageActivity` activity-alias (`VIEW_PERMISSION_USAGE` / `HEALTH_PERMISSIONS`) per architecture §4.7.4; iOS — `NSHealthShareUsageDescription` + `NSHealthUpdateUsageDescription` added to `ios/Runner/Info.plist`.
|
||||||
|
- [x] Tests: +13 — fallback driver dummy-state matrix (`fallback_health_gateway_test.dart`, 5), integrations controller connect/disconnect/status transitions (`integrations_controller_test.dart`, 6), Settings screen desktop disabled-states + mobile connect/disconnect badge round trip (`settings_screen_test.dart`, 2). Existing `_FakeHealthGateway` switched to `extends` for the inherited defaults; `app_navigation_test.dart` updated for the renamed "Health Integrations" card title.
|
||||||
|
|
||||||
|
## Phase 3B Verification Gates (2026-10-17)
|
||||||
|
- [x] `dart run build_runner build --delete-conflicting-outputs` (191 outputs written)
|
||||||
|
- [x] `flutter analyze` (No issues found!)
|
||||||
|
- [x] `flutter test` (148/148 passing)
|
||||||
|
- [x] `flutter build windows --debug` (`✓ Built build\windows\x64\runner\Debug\glpcompanion.exe`; the Settings screen's desktop-safe disabled states are asserted headlessly by `settings_screen_test.dart` — interactive `flutter run -d windows` compiles the same toolchain)
|
||||||
|
|
||||||
## Active Verification Gates (Phase 7, 2026-10-16)
|
## Active Verification Gates (Phase 7, 2026-10-16)
|
||||||
- [x] `dart analyze lib test` (Zero issues)
|
- [x] `dart analyze lib test` (Zero issues)
|
||||||
- [x] `flutter test` (all passing — 125/125)
|
- [x] `flutter test` (all passing — 125/125)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
<!-- Health Connect (Android 14+ / Play services): read permissions matching
|
<!-- Health Connect (Android 14+ / Play services): read permissions matching
|
||||||
the HealthGateway request set (weight, steps, total calories burned,
|
the HealthGateway request set (weight, steps, total calories burned,
|
||||||
active energy burned). -->
|
active energy burned, blood glucose). Health Connect federates
|
||||||
|
Samsung Health and Pixel sensor feeds through this runtime. -->
|
||||||
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_STEPS"/>
|
<uses-permission android:name="android.permission.health.READ_STEPS"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
|
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
|
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
|
||||||
|
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
|
||||||
<application
|
<application
|
||||||
android:label="glpcompanion"
|
android:label="glpcompanion"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -52,6 +54,18 @@
|
||||||
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<!-- Health Connect: lets the OS deep-link into this app's entry point
|
||||||
|
from the permission-usage screen ("View app permission usage"). -->
|
||||||
|
<activity-alias
|
||||||
|
android:name="ViewPermissionUsageActivity"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||||
|
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
@ -63,5 +77,11 @@
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- Health Connect: allow querying the installed Health Store app and
|
||||||
|
its permission-rationale activity (architecture section 4.7.4). -->
|
||||||
|
<package android:name="com.google.android.apps.healthdata" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,136 @@ Within each feature module in `lib/features/<feature_name>/`, separation of conc
|
||||||
| **Push Notifications** | `flutter_local_notifications` | Cross-platform offline-scheduled alerts for injections and hydration checks. |
|
| **Push Notifications** | `flutter_local_notifications` | Cross-platform offline-scheduled alerts for injections and hydration checks. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
### 4.7 Phase 3B: Platform Health Integrations & HealthGateway Architecture
|
||||||
|
|
||||||
|
#### 4.7.1 System Overview
|
||||||
|
The `HealthGateway` subsystem provides an abstracted, platform-agnostic bridge between application domains (Nutrition, Biometrics, Exercise) and external health telemetry ecosystems. It decouples high-level clinical metric analysis from platform-specific APIs (Apple HealthKit, Google Health Connect, Samsung Health) and external enterprise standards (SMART on FHIR), ensuring full cross-platform stability across iOS, Android, macOS, Windows, and Web.
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ IHealthGateway (Core) │
|
||||||
|
└─────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────┬───────────────┴────────────────┬────────────────────────┐
|
||||||
|
│ (iOS / macOS) │ (Android) │ (Cloud / Enterprise) │ (Windows / Web)
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Apple HealthKit │ │ Health Connect │ │ SMART on FHIR │ │ Local Fallback │
|
||||||
|
│ - Weight (lbs) │ │ & Samsung Health│ │ OAuth2 / REST │ │ - Manual inputs │
|
||||||
|
│ - Active Energy │ │ - Weight (lbs) │ │ - HbA1c panels │ │ - Local SQLite │
|
||||||
|
│ - Blood Glucose │ │ - Active Burn │ │ - Lipid markers │ │ persistence │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.7.2 Core Gateway Contracts & Data Payloads
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/platform/health_gateway.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
enum HealthProviderType {
|
||||||
|
appleHealth,
|
||||||
|
healthConnect,
|
||||||
|
samsungHealth,
|
||||||
|
enterpriseFhir,
|
||||||
|
manualFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HealthConnectionStatus {
|
||||||
|
notConfigured,
|
||||||
|
permissionDenied,
|
||||||
|
connected,
|
||||||
|
unsupportedPlatform,
|
||||||
|
}
|
||||||
|
|
||||||
|
class HealthDataPayload {
|
||||||
|
final double? weightLbs;
|
||||||
|
final double? activeCaloriesToday;
|
||||||
|
final double? bloodGlucoseMgDl;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final HealthProviderType source;
|
||||||
|
|
||||||
|
const HealthDataPayload({
|
||||||
|
this.weightLbs,
|
||||||
|
this.activeCaloriesToday,
|
||||||
|
this.bloodGlucoseMgDl,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class IHealthGateway {
|
||||||
|
/// Verifies current OS entitlement and read permissions.
|
||||||
|
Future<HealthConnectionStatus> checkStatus(HealthProviderType provider);
|
||||||
|
|
||||||
|
/// Triggers native OS permission request prompt.
|
||||||
|
Future<bool> requestAuthorization(HealthProviderType provider);
|
||||||
|
|
||||||
|
/// Queries biometrics aggregated across the current calendar day.
|
||||||
|
Future<HealthDataPayload?> fetchLatestBiometrics();
|
||||||
|
|
||||||
|
/// Revokes active session bindings or tears down local connections.
|
||||||
|
Future<void> disconnect(HealthProviderType provider);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.7.3 Platform Support & Fallback Strategy
|
||||||
|
|
||||||
|
| Target Platform | Health Provider Bridge | Mechanism / Fallback |
|
||||||
|
|---|---|---|
|
||||||
|
| **iOS** | Apple HealthKit | Native `package:health` querying `HKQuantityTypeIdentifierBodyMass`, `HKQuantityTypeIdentifierActiveEnergyBurned`, `HKQuantityTypeIdentifierBloodGlucose`. |
|
||||||
|
| **Android** | Health Connect | `androidx.health.connect.client` routing both Google Pixel and Samsung Health sensor feeds via Health Connect runtime. |
|
||||||
|
| **Windows / macOS** | Local Mock / Manual DB Driver | Native SDKs bypassed via `kIsWeb || (!Platform.isIOS && !Platform.isAndroid)` compile-time and runtime guards. Biometrics route to local Drift SQLCipher storage without crash exceptions. |
|
||||||
|
| **Web** | Web Storage Fallback | Direct Supabase cloud-synced biometrics and manual modal logging; SDK initialization omitted. |
|
||||||
|
| **Enterprise Cloud** | SMART on FHIR | Headless OAuth2 code grant returning standardized HL7 FHIR `Observation` bundles (HbA1c, metabolic panels, weight). |
|
||||||
|
|
||||||
|
#### 4.7.4 Native Entitlements & Manifest Configurations
|
||||||
|
|
||||||
|
##### Android (`android/app/src/main/AndroidManifest.xml`)
|
||||||
|
```xml
|
||||||
|
<!-- Declare Health Connect integration and permissions -->
|
||||||
|
<manifest xmlns:android="[http://schemas.android.com/apk/res/android](http://schemas.android.com/apk/res/android)">
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.google.android.apps.healthdata" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
||||||
|
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
|
||||||
|
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Activity-alias for Health Connect permissions rationale -->
|
||||||
|
<activity-alias
|
||||||
|
android:name="ViewPermissionUsageActivity"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||||
|
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### iOS (`ios/Runner/Info.plist`)
|
||||||
|
```xml
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.7.5 Security, Authorization & Key Management
|
||||||
|
1. **Zero Raw Token Exposure:** Enterprise SMART on FHIR refresh and access tokens are held exclusively in hardware-backed storage (`flutter_secure_storage` utilizing Android Keystore and Apple Keychain).
|
||||||
|
2. **Read-Only Minimal Principle:** Scopes requested from HealthKit and Health Connect are strictly restricted to read permissions for weight, blood glucose, and active burn to maximize user trust and prevent third-party telemetry leakage.
|
||||||
|
3. **Local Encryption at Rest:** Any synced telemetry data cached locally is stored inside the SQLCipher-encrypted SQLite database (`glp1_encrypted.db`), keyed by the hardware-derived master key managed by `CryptoManager`.
|
||||||
|
|
||||||
## 4. Multiplatform UX/UI Adaptation Engine
|
## 4. Multiplatform UX/UI Adaptation Engine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>GLP-1 Companion uses read-only HealthKit data to track lean body mass trends, active caloric expenditure, and glycemic markers alongside your dosing schedule.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>GLP-1 Companion records weight and dose timings into Apple Health to keep clinical history synchronized.</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'health_gateway_fallback.dart'
|
import 'health_gateway_fallback.dart'
|
||||||
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
|
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
|
||||||
|
|
||||||
|
|
@ -14,10 +16,69 @@ enum NativeHealthPlatform {
|
||||||
healthConnect,
|
healthConnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Specific health ecosystem a caller wants to address (Phase 3B contract,
|
||||||
|
/// architecture §4.7.2). [samsungHealth] federates through Health Connect on
|
||||||
|
/// Android; [enterpriseFhir] is the SMART on FHIR cloud connector (provisioning
|
||||||
|
/// pending); [manualFallback] is the always-available desktop/web mode.
|
||||||
|
enum HealthProviderType {
|
||||||
|
appleHealth,
|
||||||
|
healthConnect,
|
||||||
|
samsungHealth,
|
||||||
|
enterpriseFhir,
|
||||||
|
manualFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entitlement / read-permission state reported by
|
||||||
|
/// [IHealthGateway.checkStatus] for a [HealthProviderType].
|
||||||
|
enum HealthConnectionStatus {
|
||||||
|
/// Provider is supported but no permission decision exists yet.
|
||||||
|
notConfigured,
|
||||||
|
|
||||||
|
/// The OS health store explicitly refused (or revoked) access.
|
||||||
|
permissionDenied,
|
||||||
|
|
||||||
|
/// Entitlements + read permissions are granted.
|
||||||
|
connected,
|
||||||
|
|
||||||
|
/// The provider cannot run on this device/OS (e.g. HealthKit on Windows).
|
||||||
|
unsupportedPlatform,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Day-aggregated biometric snapshot returned by
|
||||||
|
/// [IHealthGateway.fetchLatestBiometrics] (architecture §4.7.2).
|
||||||
|
///
|
||||||
|
/// Every field is nullable: a platform may grant access to weight but not to
|
||||||
|
/// blood glucose (or vice versa), and the payload simply reports what it saw.
|
||||||
|
class HealthDataPayload {
|
||||||
|
const HealthDataPayload({
|
||||||
|
this.weightLbs,
|
||||||
|
this.activeCaloriesToday,
|
||||||
|
this.bloodGlucoseMgDl,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Latest body-mass reading of the current calendar day, in pounds.
|
||||||
|
final double? weightLbs;
|
||||||
|
|
||||||
|
/// Summed active energy expenditure for the current calendar day, in kcal.
|
||||||
|
final double? activeCaloriesToday;
|
||||||
|
|
||||||
|
/// Latest blood-glucose reading of the current calendar day, in mg/dL
|
||||||
|
/// (mmol/L samples are normalized by the gateway).
|
||||||
|
final double? bloodGlucoseMgDl;
|
||||||
|
|
||||||
|
/// When the snapshot was assembled.
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Ecosystem the numbers came from.
|
||||||
|
final HealthProviderType source;
|
||||||
|
}
|
||||||
|
|
||||||
/// Scalar sample returned by the health gateway.
|
/// Scalar sample returned by the health gateway.
|
||||||
///
|
///
|
||||||
/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
|
/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
|
||||||
/// normalization (kg → lb, joules → kcal) happens inside the gateway so
|
/// normalization (kg → lb, joules → kcal) happens inside the gateway so
|
||||||
/// consumers only ever deal in pounds and Calories.
|
/// consumers only ever deal in pounds and Calories.
|
||||||
class HealthMetric {
|
class HealthMetric {
|
||||||
const HealthMetric({
|
const HealthMetric({
|
||||||
|
|
@ -103,6 +164,91 @@ abstract class IHealthGateway {
|
||||||
required double weightLbs,
|
required double weightLbs,
|
||||||
required DateTime timestamp,
|
required DateTime timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase 3B contract (architecture §4.7.2). Default implementations are
|
||||||
|
// derived from the primitives above so hand-written fakes and future
|
||||||
|
// cloud drivers only override what they need.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The [HealthProviderType] this device's driver actually binds to, or
|
||||||
|
/// `null` in fallback mode (Web/Windows/macOS).
|
||||||
|
HealthProviderType? get activeProvider {
|
||||||
|
if (!supportsNativeHealth) return null;
|
||||||
|
final platform = nativePlatform;
|
||||||
|
if (platform == NativeHealthPlatform.appleHealth) {
|
||||||
|
return HealthProviderType.appleHealth;
|
||||||
|
}
|
||||||
|
if (platform == NativeHealthPlatform.healthConnect) {
|
||||||
|
return HealthProviderType.healthConnect;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies current OS entitlement and read permissions for [provider].
|
||||||
|
///
|
||||||
|
/// Default: `manualFallback` is always available, `enterpriseFhir` is not
|
||||||
|
/// provisioned yet, native providers map through [activeProvider].
|
||||||
|
Future<HealthConnectionStatus> checkStatus(
|
||||||
|
HealthProviderType provider) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) {
|
||||||
|
return HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
if (provider == HealthProviderType.enterpriseFhir) {
|
||||||
|
return HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
final active = activeProvider;
|
||||||
|
if (active == null) {
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
return provider == active
|
||||||
|
? HealthConnectionStatus.notConfigured
|
||||||
|
: HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers the native OS permission request prompt for [provider].
|
||||||
|
///
|
||||||
|
/// Default: manual entry needs no grant; native providers route through
|
||||||
|
/// [requestPermissions]; mismatched providers are refused.
|
||||||
|
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) return true;
|
||||||
|
if (provider != activeProvider) return false;
|
||||||
|
return requestPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries biometrics aggregated across the current calendar day.
|
||||||
|
///
|
||||||
|
/// Default: composes [fetchWeightHistory] (latest) and
|
||||||
|
/// [fetchActiveEnergyBurned] (sum); drivers may override to add types such
|
||||||
|
/// as blood glucose. Returns `null` when nothing could be read.
|
||||||
|
Future<HealthDataPayload?> fetchLatestBiometrics() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final start = DateTime(now.year, now.month, now.day);
|
||||||
|
final weights = await fetchWeightHistory(start: start, end: now);
|
||||||
|
final energies = await fetchActiveEnergyBurned(start: start, end: now);
|
||||||
|
if (weights.isEmpty && energies.isEmpty) return null;
|
||||||
|
var energyTotal = 0.0;
|
||||||
|
for (final sample in energies) {
|
||||||
|
energyTotal += sample.value;
|
||||||
|
}
|
||||||
|
return HealthDataPayload(
|
||||||
|
weightLbs: weights.isEmpty ? null : weights.first.value,
|
||||||
|
activeCaloriesToday: energies.isEmpty ? null : energyTotal,
|
||||||
|
timestamp: now,
|
||||||
|
source: activeProvider ?? HealthProviderType.manualFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes active session bindings or tears down local connections for
|
||||||
|
/// [provider]. Default: no-op (neither HealthKit nor Health Connect
|
||||||
|
/// exposes programmatic revocation to third-party apps — drivers may clear
|
||||||
|
/// cached readiness so the next [checkStatus] re-probes).
|
||||||
|
Future<void> disconnect(HealthProviderType provider) async {
|
||||||
|
debugPrint(
|
||||||
|
'HealthGateway: disconnect($provider) ignored - revoke access in the '
|
||||||
|
'system health settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-appropriate factory: native `health`-backed gateway on
|
/// Platform-appropriate factory: native `health`-backed gateway on
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,18 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||||
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
|
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
|
||||||
/// OAuth partners land in a later phase).
|
/// OAuth partners land in a later phase).
|
||||||
///
|
///
|
||||||
|
/// Extends [IHealthGateway] (rather than `implements`) so the interface's
|
||||||
|
/// Phase 3B defaults apply: `manualFallback` reports `connected`,
|
||||||
|
/// `enterpriseFhir` reports `notConfigured`, and native providers report
|
||||||
|
/// `unsupportedPlatform`.
|
||||||
|
///
|
||||||
/// It never touches a platform channel, so unsupported hardware calls can
|
/// It never touches a platform channel, so unsupported hardware calls can
|
||||||
/// never crash: permissions are refused, writes are rejected, and reads are
|
/// never crash: permissions are refused, writes are rejected, and reads are
|
||||||
/// served from the injected [ILocalBiometricReader] (the encrypted drift
|
/// served from the injected [ILocalBiometricReader] (the encrypted drift
|
||||||
/// cache of locally logged biometrics) — or from nothing when no reader was
|
/// cache of locally logged biometrics) — or from nothing when no reader was
|
||||||
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
|
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
|
||||||
/// open the manual-entry dialog.
|
/// open the manual-entry dialog.
|
||||||
class FallbackHealthGateway implements IHealthGateway {
|
class FallbackHealthGateway extends IHealthGateway {
|
||||||
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
|
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
|
||||||
: _localReader = localBiometricReader {
|
: _localReader = localBiometricReader {
|
||||||
// Debug-only diagnostic: makes the active driver obvious while
|
// Debug-only diagnostic: makes the active driver obvious while
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||||
/// `package:health`-backed gateway for Android (Health Connect) and iOS
|
/// `package:health`-backed gateway for Android (Health Connect) and iOS
|
||||||
/// (HealthKit).
|
/// (HealthKit).
|
||||||
///
|
///
|
||||||
|
/// Extends [IHealthGateway] (rather than `implements`) so the interface's
|
||||||
|
/// Phase 3B defaults (`activeProvider`, `disconnect`, ...) are inherited.
|
||||||
|
///
|
||||||
/// Guard layers (architecture rule 2/3 — zero crashes on unsupported
|
/// Guard layers (architecture rule 2/3 — zero crashes on unsupported
|
||||||
/// environments):
|
/// environments):
|
||||||
///
|
///
|
||||||
|
|
@ -31,7 +34,7 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||||
/// 4. **Defensive:** every native call is wrapped in `try/catch` so missing
|
/// 4. **Defensive:** every native call is wrapped in `try/catch` so missing
|
||||||
/// HealthKit entitlements or hardware quirks can never surface as
|
/// HealthKit entitlements or hardware quirks can never surface as
|
||||||
/// unhandled exceptions.
|
/// unhandled exceptions.
|
||||||
class MobileHealthGateway implements IHealthGateway {
|
class MobileHealthGateway extends IHealthGateway {
|
||||||
final Health _health = Health();
|
final Health _health = Health();
|
||||||
Future<void>? _configuration;
|
Future<void>? _configuration;
|
||||||
|
|
||||||
|
|
@ -40,12 +43,15 @@ class MobileHealthGateway implements IHealthGateway {
|
||||||
|
|
||||||
/// Read set requested from Health Connect (Android), mirroring the
|
/// Read set requested from Health Connect (Android), mirroring the
|
||||||
/// `READ_*` permissions declared in the Android manifest: weight plus the
|
/// `READ_*` permissions declared in the Android manifest: weight plus the
|
||||||
/// activity metrics consumed by the nutrition dashboard.
|
/// activity metrics and glycemic marker consumed by the nutrition and
|
||||||
|
/// biometric dashboards. Health Connect federates Samsung Health and
|
||||||
|
/// Pixel sensor feeds into this single runtime.
|
||||||
static const List<HealthDataType> _androidReadTypes = [
|
static const List<HealthDataType> _androidReadTypes = [
|
||||||
HealthDataType.WEIGHT,
|
HealthDataType.WEIGHT,
|
||||||
HealthDataType.STEPS,
|
HealthDataType.STEPS,
|
||||||
HealthDataType.TOTAL_CALORIES_BURNED,
|
HealthDataType.TOTAL_CALORIES_BURNED,
|
||||||
HealthDataType.ACTIVE_ENERGY_BURNED,
|
HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||||
|
HealthDataType.BLOOD_GLUCOSE,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Read set requested from HealthKit (iOS); requires the HealthKit
|
/// Read set requested from HealthKit (iOS); requires the HealthKit
|
||||||
|
|
@ -54,6 +60,7 @@ class MobileHealthGateway implements IHealthGateway {
|
||||||
static const List<HealthDataType> _iosReadTypes = [
|
static const List<HealthDataType> _iosReadTypes = [
|
||||||
HealthDataType.WEIGHT,
|
HealthDataType.WEIGHT,
|
||||||
HealthDataType.ACTIVE_ENERGY_BURNED,
|
HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||||
|
HealthDataType.BLOOD_GLUCOSE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -142,6 +149,102 @@ class MobileHealthGateway implements IHealthGateway {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [provider] routes to this device's native SDK.
|
||||||
|
///
|
||||||
|
/// Samsung Health sensor feeds (Galaxy watches, body scales) federate
|
||||||
|
/// through Health Connect on Android, so [HealthProviderType.samsungHealth]
|
||||||
|
/// aliases the healthConnect binding there.
|
||||||
|
bool _providerMatches(HealthProviderType provider) {
|
||||||
|
final active = activeProvider;
|
||||||
|
if (active == null) return false;
|
||||||
|
if (provider == active) return true;
|
||||||
|
return Platform.isAndroid &&
|
||||||
|
provider == HealthProviderType.samsungHealth &&
|
||||||
|
active == HealthProviderType.healthConnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthConnectionStatus> checkStatus(
|
||||||
|
HealthProviderType provider,
|
||||||
|
) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) {
|
||||||
|
return HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
if (provider == HealthProviderType.enterpriseFhir) {
|
||||||
|
return HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
if (!supportsNativeHealth || !_providerMatches(provider)) {
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _configure();
|
||||||
|
if (Platform.isAndroid && !await _ensureHealthConnectReady()) {
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
final granted = await _health.hasPermissions(
|
||||||
|
_readTypes,
|
||||||
|
permissions: _readPermissions,
|
||||||
|
);
|
||||||
|
return granted == true
|
||||||
|
? HealthConnectionStatus.connected
|
||||||
|
: HealthConnectionStatus.notConfigured;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'HealthGateway: checkStatus failed (non-fatal)',
|
||||||
|
);
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) return true;
|
||||||
|
if (!_providerMatches(provider)) return false;
|
||||||
|
return requestPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthDataPayload?> fetchLatestBiometrics() async {
|
||||||
|
if (!supportsNativeHealth) return null;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final start = DateTime(now.year, now.month, now.day);
|
||||||
|
final weights = await fetchWeightHistory(start: start, end: now);
|
||||||
|
final energies = await fetchActiveEnergyBurned(start: start, end: now);
|
||||||
|
final glucose = await _fetch(
|
||||||
|
type: HealthDataType.BLOOD_GLUCOSE,
|
||||||
|
start: start,
|
||||||
|
end: now,
|
||||||
|
normalize: _glucoseInMgDl,
|
||||||
|
unit: 'mg/dL',
|
||||||
|
);
|
||||||
|
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
|
||||||
|
var energyTotal = 0.0;
|
||||||
|
for (final sample in energies) {
|
||||||
|
energyTotal += sample.value;
|
||||||
|
}
|
||||||
|
return HealthDataPayload(
|
||||||
|
weightLbs: weights.isEmpty ? null : weights.first.value,
|
||||||
|
activeCaloriesToday: energies.isEmpty ? null : energyTotal,
|
||||||
|
bloodGlucoseMgDl: glucose.isEmpty ? null : glucose.first.value,
|
||||||
|
timestamp: now,
|
||||||
|
source: activeProvider ?? HealthProviderType.healthConnect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect(HealthProviderType provider) async {
|
||||||
|
if (!_providerMatches(provider)) return;
|
||||||
|
// Neither HealthKit nor Health Connect exposes programmatic permission
|
||||||
|
// revocation to third-party apps; clearing the cached SDK probe forces
|
||||||
|
// the next [checkStatus] to re-evaluate the real system state.
|
||||||
|
_healthConnectReady = null;
|
||||||
|
debugPrint(
|
||||||
|
'HealthGateway: disconnect($provider) acknowledged - revoke access in '
|
||||||
|
'the system health settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<HealthMetric>> fetchWeightHistory({
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
|
|
@ -274,4 +377,9 @@ class MobileHealthGateway implements IHealthGateway {
|
||||||
/// Active energy normally arrives in kilocalories; normalize joules too.
|
/// Active energy normally arrives in kilocalories; normalize joules too.
|
||||||
double _energyInKcal(double raw, HealthDataUnit unit) =>
|
double _energyInKcal(double raw, HealthDataUnit unit) =>
|
||||||
unit == HealthDataUnit.JOULE ? raw / 4.184 : raw;
|
unit == HealthDataUnit.JOULE ? raw / 4.184 : raw;
|
||||||
|
|
||||||
|
/// HealthKit / CGM feeds may report blood glucose in mmol/L; normalize to
|
||||||
|
/// mg/dL (x18.0182) so the rest of the app only sees one unit.
|
||||||
|
double _glucoseInMgDl(double raw, HealthDataUnit unit) =>
|
||||||
|
unit == HealthDataUnit.MILLIMOLES_PER_LITER ? raw * 18.0182 : raw;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../core/platform/health_gateway.dart';
|
import '../../../../core/platform/health_gateway.dart';
|
||||||
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
|
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
|
||||||
|
|
||||||
/// Immutable snapshot of the integrations toggles shown on the Settings
|
/// Immutable snapshot of the integrations state shown on the Settings
|
||||||
/// screen.
|
/// screen (Phase 3B: dynamic connection status per provider).
|
||||||
///
|
///
|
||||||
/// Native health toggles are gated by the platform actually backing the
|
/// Native health providers are probed through the [IHealthGateway] on every
|
||||||
/// gateway (HealthKit on iOS, Health Connect on Android); the HealthGateway
|
/// connect/disconnect round-trip; the SMART on FHIR enterprise connector
|
||||||
/// connector (cloud partner REST sync) is available everywhere and reports
|
/// reports `notConfigured` until its OAuth2 backend is provisioned.
|
||||||
/// manual-entry fallback on Web/Windows/macOS.
|
|
||||||
class IntegrationsState {
|
class IntegrationsState {
|
||||||
const IntegrationsState({
|
const IntegrationsState({
|
||||||
this.appleHealthEnabled = false,
|
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.healthConnectEnabled = false,
|
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.healthGatewayEnabled = false,
|
this.fhirStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.isRequestingPermissions = false,
|
this.isRequestingPermissions = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
@ -23,14 +22,15 @@ class IntegrationsState {
|
||||||
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
||||||
static const Object _keep = Object();
|
static const Object _keep = Object();
|
||||||
|
|
||||||
/// Apple HealthKit sync armed (iOS only).
|
/// Apple HealthKit connection status (iOS).
|
||||||
final bool appleHealthEnabled;
|
final HealthConnectionStatus appleHealthStatus;
|
||||||
|
|
||||||
/// Google Health Connect sync armed (Android only).
|
/// Google Health Connect connection status (Android; federates Samsung
|
||||||
final bool healthConnectEnabled;
|
/// Health and Pixel sensor feeds).
|
||||||
|
final HealthConnectionStatus healthConnectStatus;
|
||||||
|
|
||||||
/// HealthGateway (cloud partner / manual-entry fallback) sync armed.
|
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
|
||||||
final bool healthGatewayEnabled;
|
final HealthConnectionStatus fhirStatus;
|
||||||
|
|
||||||
/// `true` while a native permission round-trip is in flight.
|
/// `true` while a native permission round-trip is in flight.
|
||||||
final bool isRequestingPermissions;
|
final bool isRequestingPermissions;
|
||||||
|
|
@ -38,17 +38,31 @@ class IntegrationsState {
|
||||||
/// Last permission/persistence failure, surfaced inline.
|
/// Last permission/persistence failure, surfaced inline.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Whether Apple Health is granted on this device.
|
||||||
|
bool get appleHealthConnected =>
|
||||||
|
appleHealthStatus == HealthConnectionStatus.connected;
|
||||||
|
|
||||||
|
/// Whether Health Connect is granted on this device.
|
||||||
|
bool get healthConnectConnected =>
|
||||||
|
healthConnectStatus == HealthConnectionStatus.connected;
|
||||||
|
|
||||||
|
/// True when the device offers no native health SDK at all (Web/Windows/
|
||||||
|
/// macOS fallback driver) — the UI disables native connect buttons.
|
||||||
|
bool get nativeHealthUnsupported =>
|
||||||
|
appleHealthStatus == HealthConnectionStatus.unsupportedPlatform &&
|
||||||
|
healthConnectStatus == HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
|
||||||
IntegrationsState copyWith({
|
IntegrationsState copyWith({
|
||||||
bool? appleHealthEnabled,
|
HealthConnectionStatus? appleHealthStatus,
|
||||||
bool? healthConnectEnabled,
|
HealthConnectionStatus? healthConnectStatus,
|
||||||
bool? healthGatewayEnabled,
|
HealthConnectionStatus? fhirStatus,
|
||||||
bool? isRequestingPermissions,
|
bool? isRequestingPermissions,
|
||||||
Object? errorMessage = _keep,
|
Object? errorMessage = _keep,
|
||||||
}) =>
|
}) =>
|
||||||
IntegrationsState(
|
IntegrationsState(
|
||||||
appleHealthEnabled: appleHealthEnabled ?? this.appleHealthEnabled,
|
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
|
||||||
healthConnectEnabled: healthConnectEnabled ?? this.healthConnectEnabled,
|
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
|
||||||
healthGatewayEnabled: healthGatewayEnabled ?? this.healthGatewayEnabled,
|
fhirStatus: fhirStatus ?? this.fhirStatus,
|
||||||
isRequestingPermissions:
|
isRequestingPermissions:
|
||||||
isRequestingPermissions ?? this.isRequestingPermissions,
|
isRequestingPermissions ?? this.isRequestingPermissions,
|
||||||
errorMessage: identical(errorMessage, _keep)
|
errorMessage: identical(errorMessage, _keep)
|
||||||
|
|
@ -57,18 +71,28 @@ class IntegrationsState {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates the integrations toggles: requests native health
|
/// Orchestrates the integrations section: probes per-provider connection
|
||||||
/// permissions through the cross-platform [IHealthGateway] before arming a
|
/// status through the cross-platform [IHealthGateway], runs native
|
||||||
/// toggle, and degrades gracefully on platforms without native support.
|
/// authorization round-trips on connect, and tears down bindings on
|
||||||
|
/// disconnect. Degrades gracefully on platforms without native support.
|
||||||
class IntegrationsController extends Notifier<IntegrationsState> {
|
class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
|
/// Disposal sentinel: Riverpod 2.x `Ref` has no `mounted` getter, so async
|
||||||
|
/// gaps guard on this flag instead of touching `state` after teardown.
|
||||||
|
var _disposed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IntegrationsState build() {
|
IntegrationsState build() {
|
||||||
|
ref.onDispose(() => _disposed = true);
|
||||||
final gateway = ref.read(healthGatewayProvider);
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
|
// Synchronous first guess from the driver capability; the async probe
|
||||||
|
// below refines it with the real OS permission state.
|
||||||
|
final unsupported = gateway.supportsNativeHealth
|
||||||
|
? HealthConnectionStatus.notConfigured
|
||||||
|
: HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
Future<void>.microtask(refreshStatuses);
|
||||||
return IntegrationsState(
|
return IntegrationsState(
|
||||||
// Toggles start disarmed; enabling requests fresh permissions.
|
appleHealthStatus: unsupported,
|
||||||
appleHealthEnabled: false,
|
healthConnectStatus: unsupported,
|
||||||
healthConnectEnabled: false,
|
|
||||||
healthGatewayEnabled: false,
|
|
||||||
errorMessage:
|
errorMessage:
|
||||||
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
|
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
|
||||||
);
|
);
|
||||||
|
|
@ -82,19 +106,52 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
'HealthGateway runs in manual-entry mode.';
|
'HealthGateway runs in manual-entry mode.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arms/disarms the Apple Health toggle. Enabling requests native
|
/// Re-probes the connection status of every provider row.
|
||||||
/// permission; denial reverts the toggle and records a message.
|
Future<void> refreshStatuses() async {
|
||||||
Future<void> setAppleHealthEnabled(bool enabled) =>
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
_setNative(enabled: enabled, isApple: true);
|
final apple = await gateway.checkStatus(HealthProviderType.appleHealth);
|
||||||
|
if (_disposed) return;
|
||||||
|
final connect = await gateway.checkStatus(HealthProviderType.healthConnect);
|
||||||
|
if (_disposed) return;
|
||||||
|
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
|
||||||
|
if (_disposed) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
appleHealthStatus: apple,
|
||||||
|
healthConnectStatus: connect,
|
||||||
|
fhirStatus: fhir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Arms/disarms the Health Connect toggle (same permission contract as
|
/// Runs the native authorization prompt for Apple Health (iOS); a granted
|
||||||
/// Apple Health; the gateway routes to the right SDK per platform).
|
/// round-trip flips the row to `connected`, denial records a message.
|
||||||
Future<void> setHealthConnectEnabled(bool enabled) =>
|
Future<void> connectAppleHealth() =>
|
||||||
_setNative(enabled: enabled, isApple: false);
|
_connect(HealthProviderType.appleHealth);
|
||||||
|
|
||||||
/// Arms/disarms the HealthGateway connector (no native permission needed).
|
/// Runs the native authorization prompt for Health Connect (Android).
|
||||||
void setHealthGatewayEnabled(bool enabled) {
|
Future<void> connectHealthConnect() =>
|
||||||
state = state.copyWith(healthGatewayEnabled: enabled, errorMessage: null);
|
_connect(HealthProviderType.healthConnect);
|
||||||
|
|
||||||
|
/// Tears down the binding for [provider] and re-probes its status.
|
||||||
|
///
|
||||||
|
/// HealthKit / Health Connect expose no programmatic revocation to
|
||||||
|
/// third-party apps, so the gateway clears cached state and the user
|
||||||
|
/// completes revocation in system settings; the re-probe reflects either
|
||||||
|
/// outcome.
|
||||||
|
Future<void> disconnectProvider(HealthProviderType provider) async {
|
||||||
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
|
try {
|
||||||
|
await gateway.disconnect(provider);
|
||||||
|
if (_disposed) return;
|
||||||
|
_applyStatus(provider, await gateway.checkStatus(provider));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'health disconnect failed',
|
||||||
|
);
|
||||||
|
if (!_disposed) {
|
||||||
|
state = state.copyWith(errorMessage: 'Could not disconnect: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the transient error banner.
|
/// Clears the transient error banner.
|
||||||
|
|
@ -104,44 +161,56 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setNative({required bool enabled, required bool isApple}) async {
|
Future<void> _connect(HealthProviderType provider) async {
|
||||||
if (!enabled) {
|
|
||||||
state = state.copyWith(
|
|
||||||
appleHealthEnabled: isApple ? false : state.appleHealthEnabled,
|
|
||||||
healthConnectEnabled: isApple ? state.healthConnectEnabled : false,
|
|
||||||
errorMessage: null,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
|
state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
|
||||||
try {
|
try {
|
||||||
final granted = await ref.read(healthGatewayProvider).requestPermissions();
|
final granted =
|
||||||
if (granted) {
|
await ref.read(healthGatewayProvider).requestAuthorization(provider);
|
||||||
state = state.copyWith(
|
if (_disposed) return;
|
||||||
appleHealthEnabled: isApple ? true : state.appleHealthEnabled,
|
_applyStatus(
|
||||||
healthConnectEnabled: isApple ? state.healthConnectEnabled : true,
|
provider,
|
||||||
isRequestingPermissions: false,
|
granted
|
||||||
);
|
? HealthConnectionStatus.connected
|
||||||
} else {
|
: HealthConnectionStatus.permissionDenied,
|
||||||
state = state.copyWith(
|
);
|
||||||
isRequestingPermissions: false,
|
state = state.copyWith(
|
||||||
errorMessage:
|
isRequestingPermissions: false,
|
||||||
'Health permissions were denied. Enable them in your device '
|
errorMessage: granted
|
||||||
'settings to sync biometrics.',
|
? null
|
||||||
);
|
: 'Health permissions were denied. Enable them in your device '
|
||||||
}
|
'settings to sync biometrics.',
|
||||||
|
);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrintStack(
|
debugPrintStack(
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
label: 'health permission request failed',
|
label: 'health permission request failed',
|
||||||
);
|
);
|
||||||
|
if (_disposed) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isRequestingPermissions: false,
|
isRequestingPermissions: false,
|
||||||
errorMessage: 'Could not request health permissions: $error',
|
errorMessage: 'Could not request health permissions: $error',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a provider status update to the matching state field (Samsung
|
||||||
|
/// Health shares the Health Connect row because it federates through it).
|
||||||
|
void _applyStatus(
|
||||||
|
HealthProviderType provider,
|
||||||
|
HealthConnectionStatus status,
|
||||||
|
) {
|
||||||
|
switch (provider) {
|
||||||
|
case HealthProviderType.appleHealth:
|
||||||
|
state = state.copyWith(appleHealthStatus: status);
|
||||||
|
case HealthProviderType.healthConnect:
|
||||||
|
case HealthProviderType.samsungHealth:
|
||||||
|
state = state.copyWith(healthConnectStatus: status);
|
||||||
|
case HealthProviderType.enterpriseFhir:
|
||||||
|
state = state.copyWith(fhirStatus: status);
|
||||||
|
case HealthProviderType.manualFallback:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes the [IntegrationsController] to the widget tree.
|
/// Exposes the [IntegrationsController] to the widget tree.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import '../../../../core/network/supabase_status.dart';
|
import '../../../../core/network/supabase_status.dart';
|
||||||
|
import '../../../../core/platform/health_gateway.dart';
|
||||||
import '../../../auth/presentation/controllers/auth_controller.dart';
|
import '../../../auth/presentation/controllers/auth_controller.dart';
|
||||||
import '../../../nutrition/presentation/controllers/nutrition_controller.dart';
|
import '../../../nutrition/presentation/controllers/nutrition_controller.dart';
|
||||||
import '../controllers/integrations_controller.dart';
|
import '../controllers/integrations_controller.dart';
|
||||||
|
|
@ -295,7 +296,13 @@ class _SecuritySection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Integrations: Apple Health / Health Connect / HealthGateway toggles.
|
/// Integrations (Phase 3B): Apple Health / Health Connect (Samsung & Pixel)
|
||||||
|
/// / SMART on FHIR rows with dynamic status indicators and Connect /
|
||||||
|
/// Disconnect authorization buttons.
|
||||||
|
///
|
||||||
|
/// Providers the current OS cannot host (e.g. HealthKit on Windows/Web)
|
||||||
|
/// render desktop-safe disabled Connect buttons with an "Unsupported"
|
||||||
|
/// badge instead of crashing into dead platform channels.
|
||||||
class _IntegrationsSection extends ConsumerWidget {
|
class _IntegrationsSection extends ConsumerWidget {
|
||||||
const _IntegrationsSection({required this.state});
|
const _IntegrationsSection({required this.state});
|
||||||
|
|
||||||
|
|
@ -304,49 +311,55 @@ class _IntegrationsSection extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final controller = ref.read(integrationsControllerProvider.notifier);
|
||||||
|
|
||||||
return _SettingsCard(
|
return _SettingsCard(
|
||||||
title: 'Integrations',
|
title: 'Health Integrations',
|
||||||
icon: Icons.sync_outlined,
|
icon: Icons.cable_outlined,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
_IntegrationRow(
|
||||||
contentPadding: EdgeInsets.zero,
|
icon: Icons.apple,
|
||||||
secondary: const Icon(Icons.apple),
|
title: 'Apple Health',
|
||||||
title: const Text('Apple Health'),
|
subtitle: 'Weight, active energy & blood glucose (HealthKit)',
|
||||||
subtitle: const Text('Sync weight & active energy (iOS)'),
|
status: state.appleHealthStatus,
|
||||||
value: state.appleHealthEnabled,
|
busy: state.isRequestingPermissions,
|
||||||
onChanged: state.isRequestingPermissions
|
onConnect: state.isRequestingPermissions
|
||||||
? null
|
? null
|
||||||
: (enabled) => ref
|
: () => controller.connectAppleHealth(),
|
||||||
.read(integrationsControllerProvider.notifier)
|
onDisconnect: state.isRequestingPermissions
|
||||||
.setAppleHealthEnabled(enabled),
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
secondary: const Icon(Icons.fingerprint),
|
|
||||||
title: const Text('Health Connect'),
|
|
||||||
subtitle: const Text('Sync weight & active energy (Android)'),
|
|
||||||
value: state.healthConnectEnabled,
|
|
||||||
onChanged: state.isRequestingPermissions
|
|
||||||
? null
|
? null
|
||||||
: (enabled) => ref
|
: () => controller
|
||||||
.read(integrationsControllerProvider.notifier)
|
.disconnectProvider(HealthProviderType.appleHealth),
|
||||||
.setHealthConnectEnabled(enabled),
|
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
const Divider(),
|
||||||
contentPadding: EdgeInsets.zero,
|
_IntegrationRow(
|
||||||
secondary: const Icon(Icons.cloud_sync_outlined),
|
icon: Icons.fingerprint,
|
||||||
title: const Text('HealthGateway'),
|
title: 'Health Connect (Samsung & Pixel)',
|
||||||
subtitle: const Text(
|
subtitle:
|
||||||
'Cloud partner sync (MyFitnessPal / Withings) with '
|
'Federated biometrics from Samsung Health & Pixel sensors',
|
||||||
'manual-entry fallback',
|
status: state.healthConnectStatus,
|
||||||
),
|
busy: state.isRequestingPermissions,
|
||||||
value: state.healthGatewayEnabled,
|
onConnect: state.isRequestingPermissions
|
||||||
onChanged: (enabled) => ref
|
? null
|
||||||
.read(integrationsControllerProvider.notifier)
|
: () => controller.connectHealthConnect(),
|
||||||
.setHealthGatewayEnabled(enabled),
|
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)
|
if (state.isRequestingPermissions)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -372,6 +385,106 @@ class _IntegrationsSection extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single provider row: icon, title, subtitle, dynamic status badge, and
|
||||||
|
/// a Connect / Disconnect authorization button.
|
||||||
|
class _IntegrationRow extends StatelessWidget {
|
||||||
|
const _IntegrationRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.status,
|
||||||
|
required this.busy,
|
||||||
|
required this.onConnect,
|
||||||
|
required this.onDisconnect,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final HealthConnectionStatus status;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback? onConnect;
|
||||||
|
final VoidCallback? onDisconnect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final connected = status == HealthConnectionStatus.connected;
|
||||||
|
final unsupported = status == HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child:
|
||||||
|
Text(title, style: theme.textTheme.titleSmall),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: _StatusBadge(
|
||||||
|
label: _statusLabel(status),
|
||||||
|
ok: connected,
|
||||||
|
neutral: unsupported,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: theme.colorScheme.outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: connected
|
||||||
|
? OutlinedButton(
|
||||||
|
onPressed: onDisconnect,
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
)
|
||||||
|
: FilledButton.tonal(
|
||||||
|
// Desktop-safe disabled state: unsupported platforms
|
||||||
|
// and un-provisioned connectors never fire a dead call.
|
||||||
|
onPressed: unsupported ? null : onConnect,
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _statusLabel(HealthConnectionStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case HealthConnectionStatus.connected:
|
||||||
|
return 'Connected';
|
||||||
|
case HealthConnectionStatus.notConfigured:
|
||||||
|
return 'Not connected';
|
||||||
|
case HealthConnectionStatus.permissionDenied:
|
||||||
|
return 'Permission denied';
|
||||||
|
case HealthConnectionStatus.unsupportedPlatform:
|
||||||
|
return 'Unsupported';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared card chrome for a settings section.
|
/// Shared card chrome for a settings section.
|
||||||
class _SettingsCard extends StatelessWidget {
|
class _SettingsCard extends StatelessWidget {
|
||||||
const _SettingsCard({
|
const _SettingsCard({
|
||||||
|
|
@ -415,15 +528,33 @@ class _SettingsCard extends StatelessWidget {
|
||||||
|
|
||||||
/// Compact colored badge used for capability/status rows.
|
/// Compact colored badge used for capability/status rows.
|
||||||
class _StatusBadge extends StatelessWidget {
|
class _StatusBadge extends StatelessWidget {
|
||||||
const _StatusBadge({required this.label, required this.ok});
|
const _StatusBadge({
|
||||||
|
required this.label,
|
||||||
|
required this.ok,
|
||||||
|
this.neutral = false,
|
||||||
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool ok;
|
final bool ok;
|
||||||
|
|
||||||
|
/// Renders a grey "not applicable here" tone (e.g. Unsupported on
|
||||||
|
/// desktop) instead of the attention-grabbing orange.
|
||||||
|
final bool neutral;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = ok ? Colors.green.shade100 : Colors.orange.shade100;
|
final Color color;
|
||||||
final textColor = ok ? Colors.green.shade800 : Colors.orange.shade900;
|
final Color textColor;
|
||||||
|
if (neutral) {
|
||||||
|
color = Colors.grey.shade200;
|
||||||
|
textColor = Colors.grey.shade700;
|
||||||
|
} else if (ok) {
|
||||||
|
color = Colors.green.shade100;
|
||||||
|
textColor = Colors.green.shade800;
|
||||||
|
} else {
|
||||||
|
color = Colors.orange.shade100;
|
||||||
|
textColor = Colors.orange.shade900;
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/health_gateway.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/health_gateway_fallback.dart';
|
||||||
|
|
||||||
|
/// Phase 3B: the Web/Windows/macOS fallback driver must answer every
|
||||||
|
/// provider query with safe dummy states — never a platform-channel crash.
|
||||||
|
|
||||||
|
class _FakeLocalReader implements ILocalBiometricReader {
|
||||||
|
const _FakeLocalReader({this.weights = const [], this.energies = const []});
|
||||||
|
|
||||||
|
final List<HealthMetric> weights;
|
||||||
|
final List<HealthMetric> energies;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> readWeightRange({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
weights;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> readEnergyRange({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
energies;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('checkStatus answers every provider with a safe dummy state', () async {
|
||||||
|
final gateway = FallbackHealthGateway();
|
||||||
|
|
||||||
|
expect(await gateway.checkStatus(HealthProviderType.appleHealth),
|
||||||
|
HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(await gateway.checkStatus(HealthProviderType.healthConnect),
|
||||||
|
HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(await gateway.checkStatus(HealthProviderType.samsungHealth),
|
||||||
|
HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(await gateway.checkStatus(HealthProviderType.enterpriseFhir),
|
||||||
|
HealthConnectionStatus.notConfigured);
|
||||||
|
expect(await gateway.checkStatus(HealthProviderType.manualFallback),
|
||||||
|
HealthConnectionStatus.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requestAuthorization grants manual entry and refuses native SDKs',
|
||||||
|
() async {
|
||||||
|
final gateway = FallbackHealthGateway();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await gateway.requestAuthorization(HealthProviderType.manualFallback),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await gateway.requestAuthorization(HealthProviderType.healthConnect),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await gateway.requestAuthorization(HealthProviderType.appleHealth),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchLatestBiometrics serves the local cache via manualFallback',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final gateway = FallbackHealthGateway(
|
||||||
|
localBiometricReader: _FakeLocalReader(
|
||||||
|
weights: [HealthMetric(timestamp: now, value: 172.5, unit: 'lb')],
|
||||||
|
energies: [
|
||||||
|
HealthMetric(timestamp: now, value: 210, unit: 'kcal'),
|
||||||
|
HealthMetric(timestamp: now, value: 90, unit: 'kcal'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final payload = await gateway.fetchLatestBiometrics();
|
||||||
|
|
||||||
|
expect(payload, isNotNull);
|
||||||
|
expect(payload!.source, HealthProviderType.manualFallback);
|
||||||
|
expect(payload.weightLbs, 172.5);
|
||||||
|
expect(payload.activeCaloriesToday, 300);
|
||||||
|
expect(payload.bloodGlucoseMgDl, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchLatestBiometrics returns null when no local cache is wired',
|
||||||
|
() async {
|
||||||
|
final gateway = FallbackHealthGateway();
|
||||||
|
expect(await gateway.fetchLatestBiometrics(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect never throws on the fallback driver', () async {
|
||||||
|
final gateway = FallbackHealthGateway();
|
||||||
|
await expectLater(
|
||||||
|
gateway.disconnect(HealthProviderType.healthConnect),
|
||||||
|
completes,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
|
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
|
||||||
class _FakeHealthGateway implements IHealthGateway {
|
class _FakeHealthGateway extends IHealthGateway {
|
||||||
bool nativeSupported = true;
|
bool nativeSupported = true;
|
||||||
bool granted = true;
|
bool granted = true;
|
||||||
List<HealthMetric> weights = [];
|
List<HealthMetric> weights = [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/health_gateway.dart';
|
||||||
|
import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart';
|
||||||
|
import 'package:glp1_companion/features/settings/presentation/controllers/integrations_controller.dart';
|
||||||
|
|
||||||
|
/// Phase 3B: connection-status orchestration for the Settings integrations
|
||||||
|
/// section (connect / disconnect round-trips, dynamic status indicators,
|
||||||
|
/// graceful degradation on unsupported platforms).
|
||||||
|
|
||||||
|
/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect.
|
||||||
|
///
|
||||||
|
/// Extends the interface so the Phase 3B defaults stay available; the
|
||||||
|
/// scripted members mirror what the mobile driver does on a real device.
|
||||||
|
class _ScriptedHealthGateway extends IHealthGateway {
|
||||||
|
_ScriptedHealthGateway({this.nativeSupported = true});
|
||||||
|
|
||||||
|
bool nativeSupported;
|
||||||
|
bool grantOnRequest = true;
|
||||||
|
HealthConnectionStatus nativeStatus = HealthConnectionStatus.notConfigured;
|
||||||
|
final requestedProviders = <HealthProviderType>[];
|
||||||
|
final disconnectedProviders = <HealthProviderType>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsNativeHealth => nativeSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
NativeHealthPlatform? get nativePlatform =>
|
||||||
|
nativeSupported ? NativeHealthPlatform.healthConnect : null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthConnectionStatus> checkStatus(
|
||||||
|
HealthProviderType provider,
|
||||||
|
) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) {
|
||||||
|
return HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
if (provider == HealthProviderType.enterpriseFhir) {
|
||||||
|
return HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
if (!nativeSupported) return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
if (provider == HealthProviderType.healthConnect) return nativeStatus;
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||||
|
requestedProviders.add(provider);
|
||||||
|
if (provider == activeProvider && grantOnRequest) {
|
||||||
|
nativeStatus = HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
return grantOnRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect(HealthProviderType provider) async {
|
||||||
|
disconnectedProviders.add(provider);
|
||||||
|
if (provider == activeProvider) {
|
||||||
|
nativeStatus = HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async => grantOnRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> writeWeight({
|
||||||
|
required double weightLbs,
|
||||||
|
required DateTime timestamp,
|
||||||
|
}) async =>
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _ScriptedHealthGateway gateway;
|
||||||
|
late ProviderContainer container;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
gateway = _ScriptedHealthGateway();
|
||||||
|
container = ProviderContainer(
|
||||||
|
overrides: [healthGatewayProvider.overrideWithValue(gateway)],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
});
|
||||||
|
|
||||||
|
IntegrationsState state() => container.read(integrationsControllerProvider);
|
||||||
|
|
||||||
|
IntegrationsController controller() =>
|
||||||
|
container.read(integrationsControllerProvider.notifier);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'unsupported platforms disable native providers and surface the '
|
||||||
|
'fallback notice', () async {
|
||||||
|
gateway = _ScriptedHealthGateway(nativeSupported: false);
|
||||||
|
container = ProviderContainer(
|
||||||
|
overrides: [healthGatewayProvider.overrideWithValue(gateway)],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await controller().refreshStatuses();
|
||||||
|
|
||||||
|
final s = state();
|
||||||
|
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(s.healthConnectStatus, HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(s.fhirStatus, HealthConnectionStatus.notConfigured);
|
||||||
|
expect(s.nativeHealthUnsupported, isTrue);
|
||||||
|
expect(s.errorMessage, contains('manual-entry mode'));
|
||||||
|
expect(s.isRequestingPermissions, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial probe reports notConfigured native providers on mobile',
|
||||||
|
() async {
|
||||||
|
await controller().refreshStatuses();
|
||||||
|
|
||||||
|
final s = state();
|
||||||
|
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(s.healthConnectStatus, HealthConnectionStatus.notConfigured);
|
||||||
|
expect(s.fhirStatus, HealthConnectionStatus.notConfigured);
|
||||||
|
expect(s.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('granted authorization flips only the addressed row to connected',
|
||||||
|
() async {
|
||||||
|
await controller().refreshStatuses();
|
||||||
|
await controller().connectHealthConnect();
|
||||||
|
|
||||||
|
final s = state();
|
||||||
|
expect(gateway.requestedProviders, [HealthProviderType.healthConnect]);
|
||||||
|
expect(s.healthConnectStatus, HealthConnectionStatus.connected);
|
||||||
|
expect(s.healthConnectConnected, isTrue);
|
||||||
|
// The Apple Health row is untouched on an Android-backed gateway.
|
||||||
|
expect(s.appleHealthStatus, HealthConnectionStatus.unsupportedPlatform);
|
||||||
|
expect(s.errorMessage, isNull);
|
||||||
|
expect(s.isRequestingPermissions, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denied authorization records permissionDenied and an error message',
|
||||||
|
() async {
|
||||||
|
gateway.grantOnRequest = false;
|
||||||
|
|
||||||
|
await controller().connectHealthConnect();
|
||||||
|
|
||||||
|
final s = state();
|
||||||
|
expect(s.healthConnectStatus, HealthConnectionStatus.permissionDenied);
|
||||||
|
expect(s.healthConnectConnected, isFalse);
|
||||||
|
expect(s.errorMessage, contains('denied'));
|
||||||
|
expect(s.isRequestingPermissions, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect tears down the binding and re-probes the status', () async {
|
||||||
|
await controller().connectHealthConnect();
|
||||||
|
expect(state().healthConnectStatus, HealthConnectionStatus.connected);
|
||||||
|
|
||||||
|
await controller().disconnectProvider(HealthProviderType.healthConnect);
|
||||||
|
|
||||||
|
expect(gateway.disconnectedProviders, [HealthProviderType.healthConnect]);
|
||||||
|
expect(state().healthConnectStatus, HealthConnectionStatus.notConfigured);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearError resets the transient banner', () async {
|
||||||
|
gateway.grantOnRequest = false;
|
||||||
|
await controller().connectHealthConnect();
|
||||||
|
expect(state().errorMessage, isNotNull);
|
||||||
|
|
||||||
|
controller().clearError();
|
||||||
|
expect(state().errorMessage, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/database/app_database.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/health_gateway.dart';
|
||||||
|
import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart';
|
||||||
|
import 'package:glp1_companion/features/settings/presentation/screens/settings_screen.dart';
|
||||||
|
|
||||||
|
/// Phase 3B: the Settings screen must render the Health Integrations section
|
||||||
|
/// with desktop-safe disabled states when native mobile providers are
|
||||||
|
/// unavailable (Windows / Web fallback driver), and run a full connect /
|
||||||
|
/// disconnect round trip through the UI when they are.
|
||||||
|
|
||||||
|
/// Fallback driver stand-in: no native SDK anywhere.
|
||||||
|
class _UnsupportedHealthGateway extends IHealthGateway {
|
||||||
|
@override
|
||||||
|
bool get supportsNativeHealth => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
NativeHealthPlatform? get nativePlatform => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> writeWeight({
|
||||||
|
required double weightLbs,
|
||||||
|
required DateTime timestamp,
|
||||||
|
}) async =>
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android-backed stand-in: Health Connect answers status and grants on
|
||||||
|
/// request; Apple Health stays unsupported.
|
||||||
|
class _HealthConnectGateway extends _UnsupportedHealthGateway {
|
||||||
|
HealthConnectionStatus status = HealthConnectionStatus.notConfigured;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsNativeHealth => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
NativeHealthPlatform? get nativePlatform => NativeHealthPlatform.healthConnect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthConnectionStatus> checkStatus(
|
||||||
|
HealthProviderType provider,
|
||||||
|
) async {
|
||||||
|
if (provider == HealthProviderType.manualFallback) {
|
||||||
|
return HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
if (provider == HealthProviderType.enterpriseFhir) {
|
||||||
|
return HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
if (provider == HealthProviderType.healthConnect) return status;
|
||||||
|
return HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||||
|
if (provider == HealthProviderType.healthConnect) {
|
||||||
|
status = HealthConnectionStatus.connected;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect(HealthProviderType provider) async {
|
||||||
|
if (provider == HealthProviderType.healthConnect) {
|
||||||
|
status = HealthConnectionStatus.notConfigured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase database;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => database.close());
|
||||||
|
|
||||||
|
Widget buildScreen(IHealthGateway gateway) => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
appDatabaseProvider.overrideWithValue(database),
|
||||||
|
healthGatewayProvider.overrideWithValue(gateway),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: SettingsScreen()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tall phone-like viewport keeps every settings card on-screen (the
|
||||||
|
// ListView builds lazily beyond its cache extent).
|
||||||
|
void useTallViewport(WidgetTester tester) {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'desktop fallback renders integrations with disabled Connect buttons '
|
||||||
|
'and Unsupported badges', (tester) async {
|
||||||
|
useTallViewport(tester);
|
||||||
|
await tester.pumpWidget(buildScreen(_UnsupportedHealthGateway()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Health Integrations'), findsOneWidget);
|
||||||
|
expect(find.text('Apple Health'), findsOneWidget);
|
||||||
|
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
|
||||||
|
expect(find.text('SMART on FHIR'), findsOneWidget);
|
||||||
|
|
||||||
|
// Native providers are unsupported on desktop; the FHIR connector is
|
||||||
|
// not provisioned yet — every Connect affordance is disabled.
|
||||||
|
expect(find.text('Unsupported'), findsNWidgets(2));
|
||||||
|
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
|
||||||
|
expect(connectButtons, findsNWidgets(3));
|
||||||
|
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypted storage card and the Supabase sign-out action are present.
|
||||||
|
expect(find.text('SQLCipher AES-256 active'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Sign Out'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'mobile gateway runs the connect / disconnect round trip with live '
|
||||||
|
'status badges', (tester) async {
|
||||||
|
useTallViewport(tester);
|
||||||
|
final gateway = _HealthConnectGateway();
|
||||||
|
await tester.pumpWidget(buildScreen(gateway));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Health Connect row is available with an enabled Connect button (the
|
||||||
|
// FHIR row also reads "Not connected" until its connector ships).
|
||||||
|
expect(find.text('Not connected'), findsNWidgets(2));
|
||||||
|
final connectFinder = find.widgetWithText(FilledButton, 'Connect');
|
||||||
|
final allConnect = connectFinder.evaluate().toList();
|
||||||
|
final enabledIndex = allConnect
|
||||||
|
.indexWhere((element) => (element.widget as FilledButton).onPressed != null);
|
||||||
|
expect(enabledIndex, greaterThanOrEqualTo(0));
|
||||||
|
|
||||||
|
await tester.tap(connectFinder.at(enabledIndex));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Connected: badge flips and the button becomes Disconnect.
|
||||||
|
expect(find.text('Connected'), findsOneWidget);
|
||||||
|
final disconnect = find.widgetWithText(OutlinedButton, 'Disconnect');
|
||||||
|
expect(disconnect, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(disconnect);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Not connected'), findsNWidgets(2));
|
||||||
|
expect(find.text('Connected'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -257,7 +257,7 @@ void main() {
|
||||||
expect(find.text('Identity'), findsOneWidget);
|
expect(find.text('Identity'), findsOneWidget);
|
||||||
expect(find.text('Targets'), findsOneWidget);
|
expect(find.text('Targets'), findsOneWidget);
|
||||||
expect(find.text('Security & Storage'), findsOneWidget);
|
expect(find.text('Security & Storage'), findsOneWidget);
|
||||||
expect(find.text('Integrations'), findsOneWidget);
|
expect(find.text('Health Integrations'), findsOneWidget);
|
||||||
await unmountAndFlushTimers(tester);
|
await unmountAndFlushTimers(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue