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
|
||||
- **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 `<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)
|
||||
- [x] `dart analyze lib test` (Zero issues)
|
||||
- [x] `flutter test` (all passing — 125/125)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@
|
|||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<!-- Health Connect (Android 14+ / Play services): read permissions matching
|
||||
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_STEPS"/>
|
||||
<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_BLOOD_GLUCOSE"/>
|
||||
<application
|
||||
android:label="glpcompanion"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -52,6 +54,18 @@
|
|||
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
|
||||
</intent-filter>
|
||||
</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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
@ -63,5 +77,11 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</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>
|
||||
</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. |
|
||||
|
||||
---
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<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>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>? _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<HealthDataType> _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<HealthDataType> _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<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
|
||||
Future<List<HealthMetric>> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<void>.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<IntegrationsState> {
|
|||
'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<void> setAppleHealthEnabled(bool enabled) =>
|
||||
_setNative(enabled: enabled, isApple: true);
|
||||
/// Re-probes the connection status of every provider row.
|
||||
Future<void> 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<void> 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<void> 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<void> 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<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.
|
||||
|
|
@ -104,44 +161,56 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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,
|
||||
final granted =
|
||||
await ref.read(healthGatewayProvider).requestAuthorization(provider);
|
||||
if (_disposed) return;
|
||||
_applyStatus(
|
||||
provider,
|
||||
granted
|
||||
? HealthConnectionStatus.connected
|
||||
: HealthConnectionStatus.permissionDenied,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
isRequestingPermissions: false,
|
||||
errorMessage:
|
||||
'Health permissions were denied. Enable them in your device '
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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),
|
||||
),
|
||||
value: state.healthGatewayEnabled,
|
||||
onChanged: (enabled) => ref
|
||||
.read(integrationsControllerProvider.notifier)
|
||||
.setHealthGatewayEnabled(enabled),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
class _FakeHealthGateway implements IHealthGateway {
|
||||
class _FakeHealthGateway extends IHealthGateway {
|
||||
bool nativeSupported = true;
|
||||
bool granted = true;
|
||||
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('Targets'), findsOneWidget);
|
||||
expect(find.text('Security & Storage'), findsOneWidget);
|
||||
expect(find.text('Integrations'), findsOneWidget);
|
||||
expect(find.text('Health Integrations'), findsOneWidget);
|
||||
await unmountAndFlushTimers(tester);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue