Compare commits
2 Commits
298cadef32
...
17b81f567d
| Author | SHA1 | Date |
|---|---|---|
|
|
17b81f567d | |
|
|
5b5e7e2f57 |
|
|
@ -130,6 +130,26 @@
|
|||
- [x] Single-instance enforcement (2026-10-17): Supabase Auth server logs (`POST /token` grant_type=pkce → 200 at 18:40:59Z, `user_signedup` for `mtkell@kellengineering.com`) proved the Microsoft exchange completed — in the *wrong window*. Two instances were running (protocol log: launches at 14:16 AND 14:37); the PKCE verifier lives in shared secure storage under one fixed key, so the callback was forwarded (Z-order) to the non-initiating instance, which silently completed the exchange server-side while the user's window stayed on the login screen. Fix: `AcquireSingleInstanceMutex()` (`Local\GLPCompanionSingleInstance`) in the Windows runner — duplicate normal launches activate the existing window and exit; protocol activations still forward to the sole window. Callback delivery is now deterministic.
|
||||
- [x] Reactive auth-guard state (2026-10-17): `isAuthenticatedProvider` read the non-reactive `currentSession` getter inside a plain `Provider`, caching its first-read value forever (nobody invalidated it). Booting already-signed-in cached `true`, so sign-out cleared the session (Identity card → "Not signed in", button disabled) but the guard kept passing — no redirect to `/login`; live sign-in navigation was equally broken but masked because every successful entry had been a boot-time session. Fixed by `ref.watch(authStateProvider)` inside the provider so the boolean is re-derived on every GoTrue transition; test overrides (`overrideWithValue`) bypass the body and are unaffected. Hardened further: the router's `refreshListenable` bump is deferred via `Future.microtask` so the redirect guard can never re-read `isAuthenticatedProvider` inside Riverpod's provider-notification cascade (ordering edge where a stale cached value could be observed); field log confirmed the `signedOut` event and local session removal fire correctly, isolating the issue to navigation only.
|
||||
- [x] End-to-end auth field verification (2026-10-17, user-confirmed): all three passwordless modes exercised live on Windows — Google OAuth, Microsoft (Entra ID) OAuth, and email magic link — each completing the custom-scheme round trip (`com.companionglp.glpcompanion://login-callback/`) through the single-instance `WM_COPYDATA` handoff into a persisted session; sign-out now redirects to `/login` and re-authentication re-enters the app without a relaunch.
|
||||
- [x] Desktop cloud health sync — Fitbit connector (2026-10-17, architecture rule 3 "third-party OAuth REST sync" realized). SUPERSEDED THE SAME DAY — Google retired the legacy Fitbit Web API (support ended 2026-09-30, full shutdown 2026-10-30); the connector was replaced with the Google Health API connector below before ever shipping, so no legacy Fitbit code remains in the tree:
|
||||
- Core: `ICloudHealthConnector` contract (`lib/core/platform/cloud_health/`), `supportsCloudSync` on `IHealthGateway`, and a `HealthProviderType.fitbit` slot. Conditional-import discipline preserved: `fitbit_connector.dart` shims to the io implementation on `dart:io` platforms and an unsupported stub on Web (zero platform channels on web, same as the rest of the fallback driver).
|
||||
- Fitbit connector (`fitbit_connector_io.dart`): OAuth2 authorization-code + PKCE (S256) through the system browser with an RFC 8252 loopback redirect (`http://localhost:52789/callback`, port configurable via `FITBIT_REDIRECT_PORT`); tokens in flutter_secure_storage with transparent refresh + 401 retry; weight logs paged in 30-day windows and normalized kg → lb (unit detected via the profile-height heuristic — cm heights always >100, inches ≤90); active calories from today's activity summary (`activityCalories`). Pure payload parsers isolated in `fitbit_parser.dart` for unit testing.
|
||||
- Wiring: `FallbackHealthGateway` accepts injected connectors, merges cloud reads with the local drift cache (newest-first), and routes checkStatus/authorize/disconnect per provider; `createHealthGateway` threads the list through; the mobile driver ignores it (cloud providers read `unsupportedPlatform` via the interface defaults). `syncFromHealth` now runs when EITHER a native SDK or a cloud connector is available (native permission prompt only for native), persisting imports as `BiometricSource.cloudSync` (enum value that already existed for this purpose).
|
||||
- UI: Fitbit row in Settings → Health Integrations (Connect runs the browser OAuth; on success the biometrics import immediately; Disconnect clears stored tokens). Desktop explanatory copy added to the Apple Health / Health Connect rows ("Available on iOS/Android — on desktop use Log Weight on the Nutrition screen") so the disabled states read as graceful degradation, not breakage.
|
||||
- Provider decisions: **Withings** = same `ICloudHealthConnector` pattern, deferred to a follow-up (needs its own developer app + credentials). **MyFitnessPal** = NOT viable: no public API since 2018; a CSV/manual import would be the only route and is not planned.
|
||||
- Configuration (not in source control): register an app at dev.fitbit.com (OAuth 2.0, Personal type), callback URL `http://localhost:52789/callback`, scopes `weight activity`; put `FITBIT_CLIENT_ID`, `FITBIT_CLIENT_SECRET`, optionally `FITBIT_REDIRECT_PORT` in `.env`. Direct deps added: `url_launcher`, `crypto`.
|
||||
- Tests: `test/core/platform/fitbit_parser_test.dart` (metric/imperial conversion, window filtering, malformed-payload tolerance, string/number calories, height heuristic); `settings_screen_test.dart` updated for the fourth integration row.
|
||||
|
||||
- [x] Google Health API migration (2026-10-17): the Fitbit connector was replaced with `GoogleHealthConnector` against the successor API before shipping — official docs (developers.google.com/health) confirm the legacy Fitbit Web API shuts down 2026-10-30 with migration to Google Health required:
|
||||
- Connector: same `ICloudHealthConnector` framework; OAuth2 (authorization-code + PKCE, `access_type=offline&prompt=consent` for a stable refresh token) via `accounts.google.com` with the same loopback redirect; scopes `googlehealth.activity_and_fitness.readonly` + `googlehealth.health_metrics_and_measurements.readonly`; tokens under `google_health.oauth.tokens` with transparent refresh (keeps the previous refresh token when Google omits it on re-consent).
|
||||
- Data: `GET health.googleapis.com/v4/users/me/dataTypes/{weight|active-energy-burned}/dataPoints` with `pageSize`/`nextPageToken` pagination; client-side window filtering (no server filter dependency); pure parser (`google_health_parser.dart`) normalizes kg → lb via the per-point `unit` field and reads `instantaneousTime` or `timeInterval.startTime`.
|
||||
- Renames: enum `HealthProviderType.googleHealth`, provider `createGoogleHealthConnector()`, controller `connectGoogleHealth()`, Settings row "Google Health — Fitbit, Pixel & Wear OS data (replaces the Fitbit API)"; all legacy Fitbit files, parser, and tests deleted; env keys `GOOGLE_HEALTH_CLIENT_ID` / `GOOGLE_HEALTH_CLIENT_SECRET` (+ optional `GOOGLE_HEALTH_REDIRECT_PORT`); tests: `google_health_parser_test.dart` (kg/lb, interval vs instantaneous time, windowing, malformed payloads, kcal passthrough).
|
||||
- Google Cloud setup: project → enable Google Health API → OAuth consent screen (External; **Testing status limits refresh tokens to 7 days — publish the app to production status for durable tokens**) → OAuth client (Web application) with Authorized redirect URI `http://localhost:52789/callback` → copy ID/secret into `.env`.
|
||||
|
||||
## Google Health Verification Gates (2026-10-17, to run locally)
|
||||
- [ ] `flutter pub get`, `flutter analyze`, `flutter test` (parser suite + updated settings row counts).
|
||||
- [x] Google Health row wiring fix (2026-10-17, field-reported: row rendered "Unsupported" with a disabled Connect button on Windows): the `dart.library.io` conditional import routes **every io platform — including desktop — to `MobileHealthGateway`** (the fallback driver is Web-only, despite its doc comment implying desktop). The mobile driver accepted `cloudConnectors` for signature parity but discarded them, so `checkStatus(googleHealth)` fell through to the interface default → `unsupportedPlatform`. Fix: `MobileHealthGateway` now stores the connectors, exposes `supportsCloudSync`, routes checkStatus/authorize/disconnect to them first, and merges cloud reads with native reads (`Future.wait`, non-fatal on provider errors) — desktop gains its only sync source, mobile gains an optional supplement. Web behavior unchanged (stub → unsupported). Merge correction: the driver already implemented the five methods further down the class; the duplicates were removed and the existing implementations edited instead (compile-error round-trip).
|
||||
- [x] Weight-card sync gate (2026-10-17, field-reported: "Log Weight / Sync" always opened the manual dialog on desktop): `_logWeightOrSync` gated on `supportsNativeHealth` alone (predating cloud sync). Now: the button syncs when a native SDK exists OR any cloud partner holds a valid grant (new `IHealthGateway.isCloudSyncReady()` probes connector statuses; both drivers override), falls back to the manual dialog otherwise (the dedicated "Manual" button is unchanged), and the sync outcome (`Synced N weight sample(s)…`) is surfaced as a snackbar. The card's sync icon now also appears when cloud sync is wired.
|
||||
- [ ] Manual Google Health round trip on Windows: Google Cloud credentials in `.env` → Settings → Google Health → Connect → Google consent → loopback redirect → row flips to Connected → weight/active energy appear on the Nutrition dashboard as `cloudSync` entries. (Test data: log a weight in the Google Health app first — the API has no sandbox data.)
|
||||
|
||||
## Deep-Link Verification Gates (2026-10-17)
|
||||
- [x] Windows runner handoff — FIELD-PROVEN via `%TEMP%\glpcompanion-protocol.log`: protocol registration → OS chooser → second process launches with the link in argv → `WM_COPYDATA` (dwData=1026, the app_links tag) delivered to the running instance → clean forwarder exit. Observed on both the Google and Microsoft success round trips, plus the earlier `?error=` path (now surfaced on-screen by the login screen). No runner build gate remains — the running binary is the gate.
|
||||
|
|
|
|||
|
|
@ -217,6 +217,137 @@ abstract class IHealthGateway {
|
|||
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.8 Phase 3C: Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache
|
||||
|
||||
#### 4.8.1 Subsystem Overview
|
||||
The Food Ingestion subsystem bridges cloud product databases with encrypted local storage (`FoodProductsTable` in Drift SQLCipher). To defend against rate-limiting, missing products, and non-profit server downtime (e.g., Open Food Facts 503 limits), the ingestion engine implements a deterministic multi-tier resolution ladder:
|
||||
1. **L1 Local Cache:** Encrypted SQLite query by exact barcode or tokenized substring.
|
||||
2. **L2 Packaged Foods Primary (Open Food Facts):** REST API v3 queries identifying branded packaged items with a strict custom `User-Agent` and a 4-second timeout.
|
||||
3. **L3 Whole & Unbranded Foods Fallback (USDA FoodData Central):** Automated fallback querying Foundation, SR Legacy, and Survey databases for generic items, unbranded raw foods, or failed L2 lookups.
|
||||
4. **L4 Manual Ingestion:** Client-side fallback modal allowing user-defined product creation and instant SQLCipher persistence.
|
||||
|
||||
```text
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ Text Query Search │ │ Mobile Barcode Scan │
|
||||
│ ("string cheesestick") │ │ (EAN-13, UPC-A) │
|
||||
└───────────┬────────────┘ └───────────┬────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ IFoodSearchRepository │
|
||||
└─────────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────┐
|
||||
▼ (1. Check Cache) ▼ (2. Primary Query) ▼ (3. Auto Fallback)
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Local Drift Cache │ │ Open Food Facts API │ │ USDA FoodData API │
|
||||
│ (SQLCipher AES-256) │ │ (Branded / Barcode) │ │ (Whole/Raw/Fallback)│
|
||||
└──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘
|
||||
│ (Hit) │ (Success) │ (Success)
|
||||
│ └────────────┬─────────────┘
|
||||
│ │ (Write Through Cache)
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Normalized FoodProduct │
|
||||
│ (UPC, Name, Brand, Serving, Calories, Protein, Carbs, Fat, Fiber) │
|
||||
└─────────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────┴──────────────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Log to Daily Nutrition │ │ Add to Pantry Inventory │
|
||||
│ (Inserts into FoodLogsTable) │ │ (Inserts into PantryItemsTable) │
|
||||
└──────────────────────────────────────┘ └──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 4.8.2 Domain Model & Contracts
|
||||
|
||||
```dart
|
||||
// lib/features/nutrition/domain/entities/food_product.dart
|
||||
|
||||
class FoodProduct {
|
||||
final String id;
|
||||
final String? barcode;
|
||||
final String productName;
|
||||
final String? brand;
|
||||
final double servingSizeGrams;
|
||||
final String servingSizeUnit;
|
||||
final double calories;
|
||||
final double proteinGrams;
|
||||
final double carbsGrams;
|
||||
final double fatGrams;
|
||||
final double? fiberGrams;
|
||||
final double? sodiumMg;
|
||||
final String? imageUrl;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const FoodProduct({
|
||||
required this.id,
|
||||
this.barcode,
|
||||
required this.productName,
|
||||
this.brand,
|
||||
required this.servingSizeGrams,
|
||||
this.servingSizeUnit = 'g',
|
||||
required this.calories,
|
||||
required this.proteinGrams,
|
||||
required this.carbsGrams,
|
||||
required this.fatGrams,
|
||||
this.fiberGrams,
|
||||
this.sodiumMg,
|
||||
this.imageUrl,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.8.3 Persistence Schema (`FoodProductsTable`)
|
||||
|
||||
```dart
|
||||
// lib/core/database/tables/food_products_table.dart
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class FoodProductsTable extends Table {
|
||||
TextColumn get id => text()(); // UPC barcode or prefixed UUID ('usda_12345')
|
||||
TextColumn get barcode => text().nullable()();
|
||||
TextColumn get productName => text()();
|
||||
TextColumn get brand => text().nullable()();
|
||||
RealColumn get servingSizeGrams => real().withDefault(const Constant(100.0))();
|
||||
TextColumn get servingSizeUnit => text().withDefault(const Constant('g'))();
|
||||
RealColumn get calories => real()();
|
||||
RealColumn get proteinGrams => real()();
|
||||
RealColumn get carbsGrams => real()();
|
||||
RealColumn get fatGrams => real()();
|
||||
RealColumn get fiberGrams => real().nullable()();
|
||||
RealColumn get sodiumMg => real().nullable()();
|
||||
TextColumn get imageUrl => text().nullable()();
|
||||
DateTimeColumn get lastUpdated => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.8.4 Data Source Specifications & Nutrient Mapping
|
||||
|
||||
| Nutrient Metric | Open Food Facts Nutriments Key | USDA FoodData Central Nutrient ID | Normalization Rule |
|
||||
|---|---|---|---|
|
||||
| **Energy (Calories)** | `energy-kcal_serving` / `energy-kcal_100g` | `1008` (Energy) | Expressed as `kcal` (double). |
|
||||
| **Protein** | `proteins_serving` / `proteins_100g` | `1003` (Protein) | Grams per serving (double). |
|
||||
| **Carbohydrates** | `carbohydrates_serving` / `carbohydrates_100g` | `1005` (Carbohydrate, by difference) | Grams per serving (double). |
|
||||
| **Total Fat** | `fat_serving` / `fat_100g` | `1004` (Total lipid) | Grams per serving (double). |
|
||||
| **Dietary Fiber** | `fiber_serving` / `fiber_100g` | `1079` (Fiber, total dietary) | Grams per serving; nullable. |
|
||||
| **Sodium** | `sodium_serving` / `sodium_100g` | `1093` (Sodium, Na) | Converted to `mg` (OFF returns grams; multiply by 1000). |
|
||||
|
||||
#### 4.8.5 Network & Resiliency Rules
|
||||
1. **Mandatory User-Agent:** All HTTP requests to Open Food Facts must include:
|
||||
```text
|
||||
User-Agent: GLP1Companion-App/1.0 (contact: mtkell@kellengineering.com; platform: Flutter)
|
||||
```
|
||||
Anonymous or default CLI user-agents will trigger upstream proxy 503 throttling.
|
||||
2. **Circuit Timeout:** Remote calls to Open Food Facts enforce a 4-second timeout. Upon timeout or HTTP 5xx error, the client transparently routes the query to `UsdaFdcClient`.
|
||||
3. **Write-Through Local Caching:** Every successfully resolved remote payload from OFF or USDA FDC is written into `FoodProductsTable` with `insertOnConflictUpdate`. Subsequent lookups for identical barcodes or query tokens resolve locally with zero network I/O.
|
||||
|
||||
## 4. Multiplatform UX/UI Adaptation Engine
|
||||
|
||||
The UI layer implements an **Adaptive Design Strategy** rather than a lowest common denominator approach:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import '../health_gateway.dart';
|
||||
|
||||
/// Cloud health partner (Google Health, Withings, ...) reachable via OAuth2 + REST
|
||||
/// from platforms that lack a native health SDK (architecture rule 3:
|
||||
/// desktop/web third-party OAuth REST sync).
|
||||
///
|
||||
/// Implementations normalize everything into the gateway's display units
|
||||
/// (pounds / kcal) so callers never deal with provider units. Declared in
|
||||
/// `core` so features and the platform layer share one contract; the
|
||||
/// dependency points inward: implementations live beside the gateway and are
|
||||
/// injected through `createHealthGateway`.
|
||||
abstract class ICloudHealthConnector {
|
||||
/// Gateway provider slot this connector fills.
|
||||
HealthProviderType get providerType;
|
||||
|
||||
/// `connected` when a (possibly refreshed) OAuth session is stored,
|
||||
/// `notConfigured` otherwise, `unsupportedPlatform` on platforms the
|
||||
/// connector cannot run (e.g. Web).
|
||||
Future<HealthConnectionStatus> checkStatus();
|
||||
|
||||
/// Runs the OAuth2 authorization-code + PKCE round trip (opens the system
|
||||
/// browser, waits for the loopback redirect) and persists tokens.
|
||||
/// Returns whether the user completed the flow.
|
||||
Future<bool> authorize();
|
||||
|
||||
/// Clears stored tokens (local revocation; the provider-side grant stays
|
||||
/// until revoked in the partner's own settings).
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Weight samples in pounds for `[start, end]`, newest first.
|
||||
Future<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
});
|
||||
|
||||
/// Active energy burned (kcal) samples for `[start, end]`, newest first.
|
||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// Platform-selected Google Health connector entry point (the successor to
|
||||
// the retired Fitbit Web API — the Google Health API carries Fitbit, Pixel
|
||||
// and Wear OS data).
|
||||
//
|
||||
// Mirrors the gateway's conditional-import discipline: Web receives the
|
||||
// unsupported stub, every `dart:io` platform receives the real OAuth + REST
|
||||
// connector. Import this file (not the implementations) to stay
|
||||
// web-compilation-safe.
|
||||
export 'google_health_connector_stub.dart'
|
||||
if (dart.library.io) 'google_health_connector_io.dart';
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../health_gateway.dart';
|
||||
import 'cloud_health_connector.dart';
|
||||
import 'google_health_parser.dart';
|
||||
|
||||
/// Conditional-import entry point used by the gateway wiring: the real
|
||||
/// connector only exists where `dart:io` does (Windows/macOS/Linux/Android/
|
||||
/// iOS); Web falls back to the unsupported stub.
|
||||
ICloudHealthConnector createGoogleHealthConnector() =>
|
||||
GoogleHealthConnector();
|
||||
|
||||
/// OAuth2 token bundle persisted per Google Health account.
|
||||
class GoogleHealthTokens {
|
||||
const GoogleHealthTokens({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'expires_at': expiresAt.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
static GoogleHealthTokens? tryParse(Object? raw) {
|
||||
if (raw is! Map<String, dynamic>) return null;
|
||||
final access = raw['access_token'];
|
||||
final refresh = raw['refresh_token'];
|
||||
final expiresAt = raw['expires_at'];
|
||||
if (access is! String || refresh is! String || expiresAt is! num) {
|
||||
return null;
|
||||
}
|
||||
return GoogleHealthTokens(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt.toInt()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
|
||||
/// the system browser with a loopback redirect
|
||||
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
|
||||
/// transparent refresh, and normalized weight / active-energy reads from the
|
||||
/// Google Health API — the official successor to the retired Fitbit Web API
|
||||
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
|
||||
/// Health.
|
||||
///
|
||||
/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
|
||||
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
|
||||
/// them in a Google Cloud project with the Google Health API enabled; the
|
||||
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
|
||||
class GoogleHealthConnector implements ICloudHealthConnector {
|
||||
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
||||
: _client = client ?? http.Client(),
|
||||
_storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
/// Storage key for the persisted [GoogleHealthTokens] JSON.
|
||||
static const String tokenStorageKey = 'google_health.oauth.tokens';
|
||||
|
||||
static const String _authorizeEndpoint =
|
||||
'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
static const String _tokenEndpoint = 'https://oauth2.googleapis.com/token';
|
||||
static const String _apiRoot = 'https://health.googleapis.com/v4';
|
||||
|
||||
/// Read-only scopes: steps/energy live under activity & fitness, weight
|
||||
/// under health metrics (scopes page: request only what you need).
|
||||
static const String _scopes =
|
||||
'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly'
|
||||
' https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly';
|
||||
|
||||
/// Data type identifiers from the Google Health data-types index.
|
||||
static const String _weightDataType = 'weight';
|
||||
static const String _activeEnergyDataType = 'active-energy-burned';
|
||||
|
||||
static const int _pageSize = 1000;
|
||||
static const Duration _expiryMargin = Duration(minutes: 2);
|
||||
static const Duration _callbackTimeout = Duration(minutes: 5);
|
||||
|
||||
final http.Client _client;
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
String? get _clientId => dotenv.env['GOOGLE_HEALTH_CLIENT_ID'];
|
||||
String? get _clientSecret => dotenv.env['GOOGLE_HEALTH_CLIENT_SECRET'];
|
||||
|
||||
int get _redirectPort => int.tryParse(
|
||||
dotenv.env['GOOGLE_HEALTH_REDIRECT_PORT'] ?? '',
|
||||
) ??
|
||||
52789;
|
||||
|
||||
Uri get _redirectUri => Uri.parse('http://localhost:$_redirectPort/callback');
|
||||
|
||||
bool get _isConfigured =>
|
||||
(_clientId?.isNotEmpty ?? false) && (_clientSecret?.isNotEmpty ?? false);
|
||||
|
||||
@override
|
||||
HealthProviderType get providerType => HealthProviderType.googleHealth;
|
||||
|
||||
@override
|
||||
Future<HealthConnectionStatus> checkStatus() async {
|
||||
if (!_isConfigured) return HealthConnectionStatus.notConfigured;
|
||||
final tokens = await _readTokens();
|
||||
return tokens == null
|
||||
? HealthConnectionStatus.notConfigured
|
||||
: HealthConnectionStatus.connected;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> authorize() async {
|
||||
if (!_isConfigured) {
|
||||
debugPrint(
|
||||
'GoogleHealthConnector: GOOGLE_HEALTH_CLIENT_ID / '
|
||||
'GOOGLE_HEALTH_CLIENT_SECRET missing from .env - cloud sync stays '
|
||||
'unconfigured.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final verifier = _randomUrlSafe(64);
|
||||
final authorizeUrl = Uri.parse(
|
||||
'$_authorizeEndpoint?response_type=code'
|
||||
'&client_id=${Uri.encodeComponent(_clientId!)}'
|
||||
'&redirect_uri=${Uri.encodeComponent(_redirectUri.toString())}'
|
||||
'&scope=${Uri.encodeComponent(_scopes)}'
|
||||
'&code_challenge=${_codeChallengeS256(verifier)}'
|
||||
'&code_challenge_method=S256'
|
||||
// Offline access + explicit consent keep the refresh token stable
|
||||
// (Google only returns a refresh token on the first consent).
|
||||
'&access_type=offline'
|
||||
'&prompt=consent'
|
||||
'&include_granted_scopes=true',
|
||||
);
|
||||
|
||||
final server = await _bindLoopback();
|
||||
if (server == null) return false;
|
||||
try {
|
||||
if (!await launchUrl(authorizeUrl, mode: LaunchMode.platformDefault)) {
|
||||
return false;
|
||||
}
|
||||
final code = await _awaitCallbackCode(server);
|
||||
final tokens = await _requestTokens({
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'code_verifier': verifier,
|
||||
'redirect_uri': _redirectUri.toString(),
|
||||
});
|
||||
await _writeTokens(tokens);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'GoogleHealthConnector: authorization failed ($error)',
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
await server.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await _storage.delete(key: tokenStorageKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
try {
|
||||
return await _fetchMetrics(
|
||||
dataType: _weightDataType,
|
||||
start: start,
|
||||
end: end,
|
||||
parse: (payload) => googleHealthWeightMetrics(
|
||||
payload,
|
||||
since: start,
|
||||
until: end,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'GoogleHealthConnector: weight fetch failed (non-fatal)',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
try {
|
||||
return await _fetchMetrics(
|
||||
dataType: _activeEnergyDataType,
|
||||
start: start,
|
||||
end: end,
|
||||
parse: (payload) => googleHealthActiveEnergyMetrics(
|
||||
payload,
|
||||
since: start,
|
||||
until: end,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'GoogleHealthConnector: energy fetch failed (non-fatal)',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks every page of `users/me/dataTypes/{dataType}/dataPoints` (the API
|
||||
/// caps pages at `pageSize`; `nextPageToken` chains until exhausted) and
|
||||
/// merges the parsed samples, newest first. Server-side filters are
|
||||
/// deliberately avoided — the connector filters client-side against
|
||||
/// `[start, end]` so the parsing rules stay unit-tested and predictable.
|
||||
Future<List<HealthMetric>> _fetchMetrics({
|
||||
required String dataType,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
required List<HealthMetric> Function(Object? payload) parse,
|
||||
}) async {
|
||||
final tokens = await _validTokens();
|
||||
final samples = <HealthMetric>[];
|
||||
String? pageToken;
|
||||
do {
|
||||
final query = StringBuffer('pageSize=$_pageSize');
|
||||
if (pageToken != null) {
|
||||
query.write('&pageToken=${Uri.encodeComponent(pageToken)}');
|
||||
}
|
||||
final payload = await _getJson(
|
||||
tokens,
|
||||
'/users/me/dataTypes/$dataType/dataPoints?$query',
|
||||
);
|
||||
samples.addAll(parse(payload));
|
||||
pageToken =
|
||||
payload is Map<String, dynamic> ? payload['nextPageToken'] as String? : null;
|
||||
} while (pageToken != null && pageToken.isNotEmpty);
|
||||
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return samples;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loopback redirect listener (RFC 8252 native-app pattern; Google accepts
|
||||
// localhost redirect URIs registered on the OAuth client).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<HttpServer?> _bindLoopback() async {
|
||||
try {
|
||||
return await HttpServer.bind(InternetAddress.loopbackIPv4, _redirectPort);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'GoogleHealthConnector: could not bind the loopback redirect '
|
||||
'port $_redirectPort (is another app using it?)',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _awaitCallbackCode(HttpServer server) {
|
||||
final completer = Completer<String>();
|
||||
server.listen((request) async {
|
||||
final params = request.uri.queryParameters;
|
||||
final error = params['error'];
|
||||
final code = params['code'];
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(error != null
|
||||
? '<html><body><h3>Google Health sign-in failed</h3>'
|
||||
'<p>${params['error_description'] ?? error}</p>'
|
||||
'<p>You can close this window.</p></body></html>'
|
||||
: '<html><body><h3>GLP-1 Companion</h3>'
|
||||
'<p>Google Health connected. Close this window and return '
|
||||
'to the app.</p></body></html>');
|
||||
await request.response.close();
|
||||
if (completer.isCompleted) return;
|
||||
if (error != null || code == null) {
|
||||
completer.completeError(
|
||||
FormatException(
|
||||
'Google Health authorization failed: '
|
||||
'${params['error_description'] ?? error ?? 'no code returned'}',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
completer.complete(code);
|
||||
}
|
||||
});
|
||||
return completer.future.timeout(
|
||||
_callbackTimeout,
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Timed out waiting for the Google Health redirect.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token lifecycle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<GoogleHealthTokens?> _readTokens() async {
|
||||
try {
|
||||
final raw = await _storage.read(key: tokenStorageKey);
|
||||
if (raw == null) return null;
|
||||
return GoogleHealthTokens.tryParse(jsonDecode(raw));
|
||||
} catch (error) {
|
||||
debugPrint('GoogleHealthConnector: token read failed ($error)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeTokens(GoogleHealthTokens tokens) =>
|
||||
_storage.write(key: tokenStorageKey, value: jsonEncode(tokens.toJson()));
|
||||
|
||||
/// Returns a valid access token, transparently refreshing an expired one.
|
||||
Future<GoogleHealthTokens> _validTokens() async {
|
||||
final tokens = await _readTokens();
|
||||
if (tokens == null) {
|
||||
throw StateError('Google Health is not connected.');
|
||||
}
|
||||
if (DateTime.now().isBefore(tokens.expiresAt.subtract(_expiryMargin))) {
|
||||
return tokens;
|
||||
}
|
||||
final refreshed = await _requestTokens({
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': tokens.refreshToken,
|
||||
});
|
||||
await _writeTokens(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
Future<GoogleHealthTokens> _requestTokens(Map<String, String> form) async {
|
||||
final response = await _client.post(
|
||||
Uri.parse(_tokenEndpoint),
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: {
|
||||
...form,
|
||||
'client_id': _clientId!,
|
||||
'client_secret': _clientSecret!,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw http.ClientException(
|
||||
'Google Health token request failed (${response.statusCode}): '
|
||||
'${response.body}',
|
||||
);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final access = json['access_token'];
|
||||
final refresh = json['refresh_token'];
|
||||
if (access is! String) {
|
||||
throw const FormatException(
|
||||
'Google Health token response had no access_token.',
|
||||
);
|
||||
}
|
||||
final expiresIn =
|
||||
Duration(seconds: (json['expires_in'] as num?)?.toInt() ?? 3600);
|
||||
return GoogleHealthTokens(
|
||||
accessToken: access,
|
||||
// Google omits refresh_token on some re-consent flows; keep the
|
||||
// previously stored one in that case.
|
||||
refreshToken: refresh is String && refresh.isNotEmpty
|
||||
? refresh
|
||||
: (await _readTokens())?.refreshToken ?? '',
|
||||
expiresAt: DateTime.now().add(expiresIn),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<Object?> _getJson(GoogleHealthTokens tokens, String path) async {
|
||||
Uri url() => Uri.parse('$_apiRoot$path');
|
||||
var response = await _client
|
||||
.get(url(), headers: {'Authorization': 'Bearer ${tokens.accessToken}'});
|
||||
if (response.statusCode == HttpStatus.unauthorized) {
|
||||
// Server-side invalidation: refresh once and retry.
|
||||
final refreshed = await _requestTokens({
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': tokens.refreshToken,
|
||||
});
|
||||
await _writeTokens(refreshed);
|
||||
response = await _client.get(url(),
|
||||
headers: {'Authorization': 'Bearer ${refreshed.accessToken}'});
|
||||
}
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw http.ClientException(
|
||||
'Google Health request failed (${response.statusCode}) for $path',
|
||||
);
|
||||
}
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static String _randomUrlSafe(int byteCount) {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(byteCount, (_) => random.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
static String _codeChallengeS256(String verifier) =>
|
||||
base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes)
|
||||
.replaceAll('=', '');
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import '../health_gateway.dart';
|
||||
import 'cloud_health_connector.dart';
|
||||
|
||||
/// Web fallback for the Google Health connector: cloud OAuth needs a
|
||||
/// loopback redirect and secure token storage, neither of which exist on
|
||||
/// Web, so the connector reports `unsupportedPlatform` and performs nothing
|
||||
/// (the same zero-platform-channel discipline as the rest of the fallback
|
||||
/// driver).
|
||||
ICloudHealthConnector createGoogleHealthConnector() =>
|
||||
const _WebGoogleHealthConnector();
|
||||
|
||||
class _WebGoogleHealthConnector implements ICloudHealthConnector {
|
||||
const _WebGoogleHealthConnector();
|
||||
|
||||
@override
|
||||
HealthProviderType get providerType => HealthProviderType.googleHealth;
|
||||
|
||||
@override
|
||||
Future<HealthConnectionStatus> checkStatus() async =>
|
||||
HealthConnectionStatus.unsupportedPlatform;
|
||||
|
||||
@override
|
||||
Future<bool> authorize() async => false;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {}
|
||||
|
||||
@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 [];
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import '../health_gateway.dart';
|
||||
|
||||
/// Kilograms to pounds (exact conversion factor used across the app).
|
||||
const double kgToLbs = 2.20462262185;
|
||||
|
||||
/// Parses a Google Health API `dataPoints` list response for **weight**
|
||||
/// (`GET /v4/users/me/dataTypes/weight/dataPoints`) into normalized
|
||||
/// [HealthMetric] samples.
|
||||
///
|
||||
/// Each data point carries its value under the `weight` key
|
||||
/// (`{"unit": "kg" | "lb", "value": <number>}`) and its time either as
|
||||
/// `instantaneousTime` or as `timeInterval.startTime` (RFC 3339). Google
|
||||
/// stores weight in kilograms by default; the `unit` field drives
|
||||
/// normalization into pounds. Samples outside `[since, until]` are dropped
|
||||
/// (the connector does not rely on server-side filters) and the result is
|
||||
/// newest-first.
|
||||
List<HealthMetric> googleHealthWeightMetrics(
|
||||
Object? payload, {
|
||||
required DateTime since,
|
||||
required DateTime until,
|
||||
}) =>
|
||||
_metricsFromDataPoints(
|
||||
payload,
|
||||
since: since,
|
||||
until: until,
|
||||
parse: (point) {
|
||||
final weight = point['weight'];
|
||||
if (weight is! Map<String, dynamic>) return null;
|
||||
final value = weight['value'];
|
||||
if (value is! num) return null;
|
||||
final isPounds = weight['unit'] == 'lb';
|
||||
return HealthMetric(
|
||||
timestamp: DateTime.now(), // replaced by the caller
|
||||
value: isPounds ? value.toDouble() : value.toDouble() * kgToLbs,
|
||||
unit: 'lb',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Parses a Google Health API `dataPoints` list response for **active
|
||||
/// energy** (`GET /v4/users/me/dataTypes/active-energy-burned/dataPoints`)
|
||||
/// into normalized kcal [HealthMetric] samples (values pass through — the
|
||||
/// API reports energy in kcal).
|
||||
List<HealthMetric> googleHealthActiveEnergyMetrics(
|
||||
Object? payload, {
|
||||
required DateTime since,
|
||||
required DateTime until,
|
||||
}) =>
|
||||
_metricsFromDataPoints(
|
||||
payload,
|
||||
since: since,
|
||||
until: until,
|
||||
parse: (point) {
|
||||
final energy = point['activeEnergyBurned'];
|
||||
if (energy is! Map<String, dynamic>) return null;
|
||||
final value = energy['value'];
|
||||
if (value is! num) return null;
|
||||
return HealthMetric(
|
||||
timestamp: DateTime.now(), // replaced by the caller
|
||||
value: value.toDouble(),
|
||||
unit: 'kcal',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Shared dataPoints-list walk: extracts the timestamp (preferring
|
||||
/// `instantaneousTime`, falling back to `timeInterval.startTime`), applies
|
||||
/// [parse] to the typed payload, and assembles newest-first samples inside
|
||||
/// `[since, until]`.
|
||||
List<HealthMetric> _metricsFromDataPoints(
|
||||
Object? payload, {
|
||||
required DateTime since,
|
||||
required DateTime until,
|
||||
required HealthMetric? Function(Map<String, dynamic> point) parse,
|
||||
}) {
|
||||
if (payload is! Map<String, dynamic>) return const [];
|
||||
final dataPoints = payload['dataPoints'];
|
||||
if (dataPoints is! List) return const [];
|
||||
|
||||
final samples = <HealthMetric>[];
|
||||
for (final raw in dataPoints) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
final timestamp = _timestampOf(raw);
|
||||
if (timestamp == null) continue;
|
||||
if (timestamp.isBefore(since) || timestamp.isAfter(until)) continue;
|
||||
final parsed = parse(raw);
|
||||
if (parsed == null) continue;
|
||||
samples.add(
|
||||
HealthMetric(timestamp: timestamp, value: parsed.value, unit: parsed.unit),
|
||||
);
|
||||
}
|
||||
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return samples;
|
||||
}
|
||||
|
||||
/// Extracts the sample time from a data point: `instantaneousTime` first
|
||||
/// (Sample records), then `timeInterval.startTime` (Interval/Daily).
|
||||
DateTime? _timestampOf(Map<String, dynamic> point) {
|
||||
final instant = point['instantaneousTime'];
|
||||
if (instant is String) {
|
||||
final parsed = DateTime.tryParse(instant);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
final interval = point['timeInterval'];
|
||||
if (interval is Map<String, dynamic>) {
|
||||
final start = interval['startTime'];
|
||||
if (start is String) return DateTime.tryParse(start);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'cloud_health/cloud_health_connector.dart';
|
||||
import 'health_gateway_fallback.dart'
|
||||
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
|
||||
|
||||
|
|
@ -26,6 +27,12 @@ enum HealthProviderType {
|
|||
samsungHealth,
|
||||
enterpriseFhir,
|
||||
manualFallback,
|
||||
|
||||
/// Google Health cloud sync (OAuth2 + REST) — the official successor to
|
||||
/// the retired Fitbit Web API (shutdown 2026-10-30); carries Fitbit,
|
||||
/// Pixel and Wear OS data. Wired through the cloud-connector framework
|
||||
/// into the fallback driver for desktop/web.
|
||||
googleHealth,
|
||||
}
|
||||
|
||||
/// Entitlement / read-permission state reported by
|
||||
|
|
@ -133,6 +140,17 @@ abstract class IHealthGateway {
|
|||
/// Web/Windows/macOS, which signals the UI to offer manual entry.
|
||||
bool get supportsNativeHealth;
|
||||
|
||||
/// Whether a cloud OAuth partner (Google Health, ...) is wired into this driver
|
||||
/// and can back a sync even without a native health SDK. `false` by
|
||||
/// default; the fallback driver flips it when cloud connectors are
|
||||
/// injected.
|
||||
bool get supportsCloudSync => false;
|
||||
|
||||
/// Whether any injected cloud partner currently holds a valid grant, i.e.
|
||||
/// a cloud-backed sync would return partner data. `false` by default;
|
||||
/// drivers with connectors override by probing each connector's status.
|
||||
Future<bool> isCloudSyncReady() async => false;
|
||||
|
||||
/// Ecosystem backing [supportsNativeHealth], or `null` in fallback mode.
|
||||
NativeHealthPlatform? get nativePlatform;
|
||||
|
||||
|
|
@ -256,5 +274,11 @@ abstract class IHealthGateway {
|
|||
///
|
||||
/// [localReader] backs fallback reads with the locally logged biometric
|
||||
/// cache; mobile platforms ignore it and query the native store directly.
|
||||
IHealthGateway createHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||
impl.makeHealthGateway(localReader: localReader);
|
||||
IHealthGateway createHealthGateway({
|
||||
ILocalBiometricReader? localReader,
|
||||
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||
}) =>
|
||||
impl.makeHealthGateway(
|
||||
localReader: localReader,
|
||||
cloudConnectors: cloudConnectors,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'cloud_health/cloud_health_connector.dart';
|
||||
import 'health_gateway.dart';
|
||||
|
||||
/// Conditional-import entry point used by `health_gateway.dart`.
|
||||
///
|
||||
/// [localReader] (usually the drift-backed biometric cache) lets fallback
|
||||
/// reads serve locally logged data instead of nothing.
|
||||
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||
FallbackHealthGateway(localBiometricReader: localReader);
|
||||
/// reads serve locally logged data instead of nothing. [cloudConnectors]
|
||||
/// add third-party OAuth REST sync (Google Health, ...) on desktop/web.
|
||||
IHealthGateway makeHealthGateway({
|
||||
ILocalBiometricReader? localReader,
|
||||
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||
}) =>
|
||||
FallbackHealthGateway(
|
||||
localBiometricReader: localReader,
|
||||
cloudConnectors: cloudConnectors,
|
||||
);
|
||||
|
||||
/// Graceful-degradation gateway for Web, Windows, macOS, and Linux
|
||||
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
|
||||
|
|
@ -25,8 +33,14 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
|||
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
|
||||
/// open the manual-entry dialog.
|
||||
class FallbackHealthGateway extends IHealthGateway {
|
||||
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
|
||||
: _localReader = localBiometricReader {
|
||||
FallbackHealthGateway({
|
||||
ILocalBiometricReader? localBiometricReader,
|
||||
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||
}) : _localReader = localBiometricReader,
|
||||
_cloudConnectors = {
|
||||
for (final connector in cloudConnectors)
|
||||
connector.providerType: connector,
|
||||
} {
|
||||
// Debug-only diagnostic: makes the active driver obvious while
|
||||
// troubleshooting platform issues; `debugPrint` is stripped in release.
|
||||
debugPrint(
|
||||
|
|
@ -38,28 +52,85 @@ class FallbackHealthGateway extends IHealthGateway {
|
|||
|
||||
final ILocalBiometricReader? _localReader;
|
||||
|
||||
/// Cloud partners (Google Health, ...) keyed by the provider slot they fill.
|
||||
final Map<HealthProviderType, ICloudHealthConnector> _cloudConnectors;
|
||||
|
||||
@override
|
||||
bool get supportsNativeHealth => false;
|
||||
|
||||
@override
|
||||
bool get supportsCloudSync => _cloudConnectors.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<bool> isCloudSyncReady() async {
|
||||
for (final connector in _cloudConnectors.values) {
|
||||
if (await connector.checkStatus() == HealthConnectionStatus.connected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
NativeHealthPlatform? get nativePlatform => null;
|
||||
|
||||
@override
|
||||
Future<bool> requestPermissions() async => false;
|
||||
|
||||
@override
|
||||
Future<HealthConnectionStatus> checkStatus(HealthProviderType provider) =>
|
||||
_cloudConnectors[provider]?.checkStatus() ?? super.checkStatus(provider);
|
||||
|
||||
@override
|
||||
Future<bool> requestAuthorization(HealthProviderType provider) {
|
||||
final connector = _cloudConnectors[provider];
|
||||
if (connector != null) return connector.authorize();
|
||||
return super.requestAuthorization(provider);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect(HealthProviderType provider) async {
|
||||
final connector = _cloudConnectors[provider];
|
||||
if (connector != null) {
|
||||
await connector.disconnect();
|
||||
return;
|
||||
}
|
||||
await super.disconnect(provider);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) =>
|
||||
_read(() => _localReader?.readWeightRange(start: start, end: end));
|
||||
}) async {
|
||||
final local =
|
||||
await _read(() => _localReader?.readWeightRange(start: start, end: end));
|
||||
final merged = [...local];
|
||||
for (final connector in _cloudConnectors.values) {
|
||||
merged.addAll(
|
||||
await _cloudRead(() => connector.fetchWeightHistory(start: start, end: end)),
|
||||
);
|
||||
}
|
||||
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) =>
|
||||
_read(() => _localReader?.readEnergyRange(start: start, end: end));
|
||||
}) async {
|
||||
final local =
|
||||
await _read(() => _localReader?.readEnergyRange(start: start, end: end));
|
||||
final merged = [...local];
|
||||
for (final connector in _cloudConnectors.values) {
|
||||
merged.addAll(
|
||||
await _cloudRead(
|
||||
() => connector.fetchActiveEnergyBurned(start: start, end: end),
|
||||
),
|
||||
);
|
||||
}
|
||||
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeWeight({
|
||||
|
|
@ -88,4 +159,21 @@ class FallbackHealthGateway extends IHealthGateway {
|
|||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloud connector read that degrades to an empty result (never throws)
|
||||
/// when the partner API errors, so a provider outage can never break the
|
||||
/// local/manual data path.
|
||||
Future<List<HealthMetric>> _cloudRead(
|
||||
Future<List<HealthMetric>> Function() read,
|
||||
) async {
|
||||
try {
|
||||
return await read();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'HealthGateway: cloud connector read failed (non-fatal)',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,15 +3,23 @@ import 'dart:io';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:health/health.dart';
|
||||
|
||||
import 'cloud_health/cloud_health_connector.dart';
|
||||
import 'health_gateway.dart';
|
||||
|
||||
/// Conditional-import entry point used by `health_gateway.dart`.
|
||||
///
|
||||
/// [localReader] is accepted for factory-signature parity with the fallback
|
||||
/// driver; native platforms never need it because reads are served by
|
||||
/// Health Connect / HealthKit directly.
|
||||
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||
MobileHealthGateway();
|
||||
/// This io-platform driver runs on Android, iOS, **and desktop** (the
|
||||
/// `dart.library.io` conditional import routes every non-Web platform here;
|
||||
/// the fallback driver is Web-only). [cloudConnectors] are therefore stored,
|
||||
/// not discarded: desktop builds rely on them as their only sync source,
|
||||
/// mobile builds gain them alongside the native store. [localReader] is kept
|
||||
/// for signature parity — io platforms serve reads from Health Connect /
|
||||
/// HealthKit directly.
|
||||
IHealthGateway makeHealthGateway({
|
||||
ILocalBiometricReader? localReader,
|
||||
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||
}) =>
|
||||
MobileHealthGateway(cloudConnectors: cloudConnectors);
|
||||
|
||||
/// `package:health`-backed gateway for Android (Health Connect) and iOS
|
||||
/// (HealthKit).
|
||||
|
|
@ -35,9 +43,34 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
|||
/// HealthKit entitlements or hardware quirks can never surface as
|
||||
/// unhandled exceptions.
|
||||
class MobileHealthGateway extends IHealthGateway {
|
||||
MobileHealthGateway({
|
||||
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||
}) : _cloudConnectors = {
|
||||
for (final connector in cloudConnectors)
|
||||
connector.providerType: connector,
|
||||
};
|
||||
|
||||
final Health _health = Health();
|
||||
Future<void>? _configuration;
|
||||
|
||||
/// Cloud partners (Google Health, ...) keyed by the provider slot they
|
||||
/// fill; this is desktop's only sync source and an optional supplement on
|
||||
/// mobile.
|
||||
final Map<HealthProviderType, ICloudHealthConnector> _cloudConnectors;
|
||||
|
||||
@override
|
||||
bool get supportsCloudSync => _cloudConnectors.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<bool> isCloudSyncReady() async {
|
||||
for (final connector in _cloudConnectors.values) {
|
||||
if (await connector.checkStatus() == HealthConnectionStatus.connected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Health Connect SDK availability, probed once per gateway instance.
|
||||
Future<bool>? _healthConnectReady;
|
||||
|
||||
|
|
@ -83,6 +116,25 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
: NativeHealthPlatform.healthConnect;
|
||||
}
|
||||
|
||||
/// Awaits cloud connector reads concurrently and degrades to an empty
|
||||
/// result (never throws) when the partner API errors, so a provider
|
||||
/// outage can never break the native/manual data path.
|
||||
Future<List<HealthMetric>> _cloudFetch(
|
||||
Iterable<Future<List<HealthMetric>>> pending,
|
||||
) async {
|
||||
if (pending.isEmpty) return const [];
|
||||
try {
|
||||
final results = await Future.wait(pending);
|
||||
return [for (final batch in results) ...batch];
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'HealthGateway: cloud connector read failed (non-fatal)',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// `Health.configure()` resolves device info and must run exactly once
|
||||
/// before any read/write call.
|
||||
Future<void> _configure() => _configuration ??= _health.configure();
|
||||
|
|
@ -167,6 +219,10 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
Future<HealthConnectionStatus> checkStatus(
|
||||
HealthProviderType provider,
|
||||
) async {
|
||||
// Cloud partners (Google Health) manage their own status; on desktop
|
||||
// they are the only supported sync source.
|
||||
final cloudConnector = _cloudConnectors[provider];
|
||||
if (cloudConnector != null) return cloudConnector.checkStatus();
|
||||
if (provider == HealthProviderType.manualFallback) {
|
||||
return HealthConnectionStatus.connected;
|
||||
}
|
||||
|
|
@ -199,6 +255,8 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
|
||||
@override
|
||||
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||
final cloudConnector = _cloudConnectors[provider];
|
||||
if (cloudConnector != null) return cloudConnector.authorize();
|
||||
if (provider == HealthProviderType.manualFallback) return true;
|
||||
if (!_providerMatches(provider)) return false;
|
||||
return requestPermissions();
|
||||
|
|
@ -206,18 +264,21 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
|
||||
@override
|
||||
Future<HealthDataPayload?> fetchLatestBiometrics() async {
|
||||
if (!supportsNativeHealth) return null;
|
||||
if (!supportsNativeHealth && !supportsCloudSync) 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',
|
||||
);
|
||||
// Blood glucose is native-only; the cloud connectors don't expose it.
|
||||
final glucose = supportsNativeHealth
|
||||
? await _fetch(
|
||||
type: HealthDataType.BLOOD_GLUCOSE,
|
||||
start: start,
|
||||
end: now,
|
||||
normalize: _glucoseInMgDl,
|
||||
unit: 'mg/dL',
|
||||
)
|
||||
: const <HealthMetric>[];
|
||||
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
|
||||
var energyTotal = 0.0;
|
||||
for (final sample in energies) {
|
||||
|
|
@ -234,6 +295,11 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
|
||||
@override
|
||||
Future<void> disconnect(HealthProviderType provider) async {
|
||||
final cloudConnector = _cloudConnectors[provider];
|
||||
if (cloudConnector != null) {
|
||||
await cloudConnector.disconnect();
|
||||
return;
|
||||
}
|
||||
if (!_providerMatches(provider)) return;
|
||||
// Neither HealthKit nor Health Connect exposes programmatic permission
|
||||
// revocation to third-party apps; clearing the cached SDK probe forces
|
||||
|
|
@ -249,27 +315,43 @@ class MobileHealthGateway extends IHealthGateway {
|
|||
Future<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) =>
|
||||
_fetch(
|
||||
}) async {
|
||||
final merged = [
|
||||
...await _fetch(
|
||||
type: HealthDataType.WEIGHT,
|
||||
start: start,
|
||||
end: end,
|
||||
normalize: _weightInLbs,
|
||||
unit: 'lb',
|
||||
);
|
||||
),
|
||||
...await _cloudFetch(
|
||||
_cloudConnectors.values
|
||||
.map((c) => c.fetchWeightHistory(start: start, end: end)),
|
||||
),
|
||||
];
|
||||
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) =>
|
||||
_fetch(
|
||||
}) async {
|
||||
final merged = [
|
||||
...await _fetch(
|
||||
type: HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||
start: start,
|
||||
end: end,
|
||||
normalize: _energyInKcal,
|
||||
unit: 'kcal',
|
||||
);
|
||||
),
|
||||
...await _cloudFetch(
|
||||
_cloudConnectors.values
|
||||
.map((c) => c.fetchActiveEnergyBurned(start: start, end: end)),
|
||||
),
|
||||
];
|
||||
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeWeight({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../../../core/platform/cloud_health/google_health_connector.dart';
|
||||
import '../../../../core/platform/health_gateway.dart';
|
||||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import '../../data/repositories/health_sync_repository.dart';
|
||||
|
|
@ -16,11 +17,13 @@ final healthSyncRepositoryProvider = Provider<IHealthSyncRepository>(
|
|||
);
|
||||
|
||||
/// Cross-platform health boundary (HealthKit / Health Connect on mobile,
|
||||
/// manual-entry fallback on Web/Windows/macOS).
|
||||
/// manual-entry fallback + cloud OAuth connectors (Google Health) on
|
||||
/// Web/Windows/macOS).
|
||||
///
|
||||
/// The fallback driver receives the encrypted drift cache as its
|
||||
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
|
||||
/// biometrics instead of returning nothing.
|
||||
/// biometrics, and the Google Health cloud connector so desktop/web can also
|
||||
/// sync from a third-party OAuth partner (Fitbit, Pixel and Wear OS data).
|
||||
///
|
||||
/// Overridable in tests with a fake gateway.
|
||||
final healthGatewayProvider = Provider<IHealthGateway>(
|
||||
|
|
@ -28,6 +31,7 @@ final healthGatewayProvider = Provider<IHealthGateway>(
|
|||
localReader: DriftLocalBiometricReader(
|
||||
ref.watch(healthSyncRepositoryProvider),
|
||||
),
|
||||
cloudConnectors: [createGoogleHealthConnector()],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -210,16 +214,17 @@ class BiometricController extends AsyncNotifier<BiometricState> {
|
|||
}
|
||||
|
||||
/// Imports weight and active-energy samples from the native health store
|
||||
/// over the trailing [window] and persists them idempotently (the previous
|
||||
/// import for the same source/window is replaced, not duplicated).
|
||||
/// (or a connected cloud partner such as Fitbit) over the trailing
|
||||
/// [window] and persists them idempotently (the previous import for the
|
||||
/// same source/window is replaced, not duplicated).
|
||||
///
|
||||
/// Returns `false` when the platform is unsupported (Web/Windows/macOS —
|
||||
/// callers should open the manual-entry dialog instead), when permission
|
||||
/// is denied, or when the sync throws; the reason is reflected in
|
||||
/// Returns `false` when no sync source exists on this platform (Web with
|
||||
/// no cloud connector provisioned), when permission is denied, or when
|
||||
/// the sync throws; the reason is reflected in
|
||||
/// [BiometricState.statusMessage].
|
||||
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
|
||||
final gateway = ref.read(healthGatewayProvider);
|
||||
if (!gateway.supportsNativeHealth) {
|
||||
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
|
||||
await _refresh(
|
||||
statusMessage:
|
||||
'Native health sync is unavailable here - use manual entry.',
|
||||
|
|
@ -239,13 +244,17 @@ class BiometricController extends AsyncNotifier<BiometricState> {
|
|||
);
|
||||
|
||||
try {
|
||||
final granted = await gateway.requestPermissions();
|
||||
if (!granted) {
|
||||
await _refresh(
|
||||
statusMessage: 'Health permissions were denied.',
|
||||
error: StateError('Health permissions denied'),
|
||||
);
|
||||
return false;
|
||||
// Native stores need an OS permission grant first; cloud connectors
|
||||
// manage their own OAuth grant at connect time.
|
||||
if (gateway.supportsNativeHealth) {
|
||||
final granted = await gateway.requestPermissions();
|
||||
if (!granted) {
|
||||
await _refresh(
|
||||
statusMessage: 'Health permissions were denied.',
|
||||
error: StateError('Health permissions denied'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
|
|
|||
|
|
@ -289,7 +289,9 @@ class _WeightTrackerCard extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final biometric = ref.watch(biometricControllerProvider);
|
||||
final supported = ref.watch(healthGatewayProvider).supportsNativeHealth;
|
||||
final gateway = ref.watch(healthGatewayProvider);
|
||||
final supported =
|
||||
gateway.supportsNativeHealth || gateway.supportsCloudSync;
|
||||
|
||||
// Surface sync/manual outcomes as snackbars whenever the status changes.
|
||||
ref.listen(biometricControllerProvider, (previous, next) {
|
||||
|
|
@ -453,15 +455,28 @@ class _WeightTrackerCard extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
/// Native platforms run the health sync; Web/Windows/macOS open the manual
|
||||
/// entry dialog (the gateway reports `supportsNativeHealth == false`).
|
||||
/// Syncs from the first available source — HealthKit / Health Connect on
|
||||
/// mobile, a connected cloud partner (Google Health) on desktop/web — and
|
||||
/// surfaces the sync outcome as a snackbar. Falls back to the manual entry
|
||||
/// dialog when no source is available or connected (the dedicated
|
||||
/// "Manual" button always opens it regardless).
|
||||
Future<void> _logWeightOrSync(BuildContext context, WidgetRef ref) async {
|
||||
final supported = ref.read(healthGatewayProvider).supportsNativeHealth;
|
||||
if (!supported) {
|
||||
final gateway = ref.read(healthGatewayProvider);
|
||||
final canSync =
|
||||
gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
|
||||
if (!canSync) {
|
||||
await showLogWeightDialog(context);
|
||||
return;
|
||||
}
|
||||
await ref.read(biometricControllerProvider.notifier).syncFromHealth();
|
||||
if (!context.mounted) return;
|
||||
final message =
|
||||
ref.read(biometricControllerProvider).valueOrNull?.statusMessage;
|
||||
if (message != null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -15,6 +17,7 @@ class IntegrationsState {
|
|||
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
|
||||
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
|
||||
this.fhirStatus = HealthConnectionStatus.notConfigured,
|
||||
this.googleHealthStatus = HealthConnectionStatus.notConfigured,
|
||||
this.isRequestingPermissions = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
|
@ -32,6 +35,9 @@ class IntegrationsState {
|
|||
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
|
||||
final HealthConnectionStatus fhirStatus;
|
||||
|
||||
/// Google Health cloud connector status (OAuth2 + REST; desktop/web)..
|
||||
final HealthConnectionStatus googleHealthStatus;
|
||||
|
||||
/// `true` while a native permission round-trip is in flight.
|
||||
final bool isRequestingPermissions;
|
||||
|
||||
|
|
@ -46,6 +52,10 @@ class IntegrationsState {
|
|||
bool get healthConnectConnected =>
|
||||
healthConnectStatus == HealthConnectionStatus.connected;
|
||||
|
||||
/// Whether the Google Health cloud connector holds a valid OAuth grant.
|
||||
bool get googleHealthConnected =>
|
||||
googleHealthStatus == 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 =>
|
||||
|
|
@ -56,6 +66,7 @@ class IntegrationsState {
|
|||
HealthConnectionStatus? appleHealthStatus,
|
||||
HealthConnectionStatus? healthConnectStatus,
|
||||
HealthConnectionStatus? fhirStatus,
|
||||
HealthConnectionStatus? googleHealthStatus,
|
||||
bool? isRequestingPermissions,
|
||||
Object? errorMessage = _keep,
|
||||
}) =>
|
||||
|
|
@ -63,6 +74,7 @@ class IntegrationsState {
|
|||
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
|
||||
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
|
||||
fhirStatus: fhirStatus ?? this.fhirStatus,
|
||||
googleHealthStatus: googleHealthStatus ?? this.googleHealthStatus,
|
||||
isRequestingPermissions:
|
||||
isRequestingPermissions ?? this.isRequestingPermissions,
|
||||
errorMessage: identical(errorMessage, _keep)
|
||||
|
|
@ -115,10 +127,13 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
|||
if (_disposed) return;
|
||||
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
|
||||
if (_disposed) return;
|
||||
final googleHealth = await gateway.checkStatus(HealthProviderType.googleHealth);
|
||||
if (_disposed) return;
|
||||
state = state.copyWith(
|
||||
appleHealthStatus: apple,
|
||||
healthConnectStatus: connect,
|
||||
fhirStatus: fhir,
|
||||
googleHealthStatus: googleHealth,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +146,17 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
|||
Future<void> connectHealthConnect() =>
|
||||
_connect(HealthProviderType.healthConnect);
|
||||
|
||||
/// Runs the Google Health cloud OAuth round trip (system browser + loopback
|
||||
/// redirect); a granted round-trip immediately imports the account's
|
||||
/// biometrics through the biometric controller.
|
||||
Future<void> connectGoogleHealth() async {
|
||||
await _connect(HealthProviderType.googleHealth);
|
||||
if (_disposed || !state.googleHealthConnected) return;
|
||||
unawaited(
|
||||
ref.read(biometricControllerProvider.notifier).syncFromHealth(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tears down the binding for [provider] and re-probes its status.
|
||||
///
|
||||
/// HealthKit / Health Connect expose no programmatic revocation to
|
||||
|
|
@ -207,6 +233,8 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
|||
state = state.copyWith(healthConnectStatus: status);
|
||||
case HealthProviderType.enterpriseFhir:
|
||||
state = state.copyWith(fhirStatus: status);
|
||||
case HealthProviderType.googleHealth:
|
||||
state = state.copyWith(googleHealthStatus: status);
|
||||
case HealthProviderType.manualFallback:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,7 +322,11 @@ class _IntegrationsSection extends ConsumerWidget {
|
|||
_IntegrationRow(
|
||||
icon: Icons.apple,
|
||||
title: 'Apple Health',
|
||||
subtitle: 'Weight, active energy & blood glucose (HealthKit)',
|
||||
subtitle: state.nativeHealthUnsupported
|
||||
? 'Weight, active energy & blood glucose (HealthKit) · '
|
||||
'Available on iOS/Android — on desktop use Log Weight '
|
||||
'on the Nutrition screen'
|
||||
: 'Weight, active energy & blood glucose (HealthKit)',
|
||||
status: state.appleHealthStatus,
|
||||
busy: state.isRequestingPermissions,
|
||||
onConnect: state.isRequestingPermissions
|
||||
|
|
@ -337,8 +341,11 @@ class _IntegrationsSection extends ConsumerWidget {
|
|||
_IntegrationRow(
|
||||
icon: Icons.fingerprint,
|
||||
title: 'Health Connect (Samsung & Pixel)',
|
||||
subtitle:
|
||||
'Federated biometrics from Samsung Health & Pixel sensors',
|
||||
subtitle: state.nativeHealthUnsupported
|
||||
? 'Federated biometrics from Samsung Health & Pixel sensors'
|
||||
' · Available on Android — on desktop use Log Weight '
|
||||
'on the Nutrition screen'
|
||||
: 'Federated biometrics from Samsung Health & Pixel sensors',
|
||||
status: state.healthConnectStatus,
|
||||
busy: state.isRequestingPermissions,
|
||||
onConnect: state.isRequestingPermissions
|
||||
|
|
@ -350,6 +357,21 @@ class _IntegrationsSection extends ConsumerWidget {
|
|||
.disconnectProvider(HealthProviderType.healthConnect),
|
||||
),
|
||||
const Divider(),
|
||||
_IntegrationRow(
|
||||
icon: Icons.favorite_outline,
|
||||
title: 'Google Health',
|
||||
subtitle: 'Fitbit, Pixel & Wear OS data (replaces the Fitbit API)',
|
||||
status: state.googleHealthStatus,
|
||||
busy: state.isRequestingPermissions,
|
||||
onConnect: state.isRequestingPermissions
|
||||
? null
|
||||
: () => controller.connectGoogleHealth(),
|
||||
onDisconnect: state.isRequestingPermissions
|
||||
? null
|
||||
: () => controller
|
||||
.disconnectProvider(HealthProviderType.googleHealth),
|
||||
),
|
||||
const Divider(),
|
||||
_IntegrationRow(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'SMART on FHIR',
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
@ -1075,7 +1075,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ dependencies:
|
|||
health: 13.3.1
|
||||
flutter_local_notifications: ^17.1.0
|
||||
|
||||
crypto: ^3.0.3
|
||||
flutter_dotenv: ^5.1.0
|
||||
logging: ^1.3.0
|
||||
supabase_flutter: ^2.5.0
|
||||
url_launcher: ^6.3.0
|
||||
path: ^1.9.1
|
||||
path_provider: ^2.1.6
|
||||
sqlite3: ^3.4.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:glp1_companion/core/platform/cloud_health/google_health_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('googleHealthWeightMetrics', () {
|
||||
final since = DateTime(2026, 10, 1);
|
||||
final until = DateTime(2026, 10, 17, 12);
|
||||
|
||||
test('converts kg samples to pounds and honors the window', () {
|
||||
final samples = googleHealthWeightMetrics(
|
||||
{
|
||||
'dataPoints': [
|
||||
{
|
||||
'weight': {'unit': 'kg', 'value': 80.0},
|
||||
'instantaneousTime': '2026-10-16T07:15:00Z',
|
||||
},
|
||||
{
|
||||
'weight': {'unit': 'kg', 'value': 81.5},
|
||||
'timeInterval': {'startTime': '2026-10-15T07:05:00Z'},
|
||||
},
|
||||
{
|
||||
'weight': {'unit': 'kg', 'value': 90.0},
|
||||
'instantaneousTime': '2025-01-01T00:00:00Z', // outside window
|
||||
},
|
||||
{
|
||||
'instantaneousTime': '2026-10-14T07:00:00Z', // missing weight
|
||||
},
|
||||
],
|
||||
},
|
||||
since: since,
|
||||
until: until,
|
||||
);
|
||||
|
||||
expect(samples, hasLength(2));
|
||||
expect(samples.first.value, closeTo(80.0 * kgToLbs, 1e-9));
|
||||
expect(samples.first.unit, 'lb');
|
||||
expect(samples.first.timestamp.isUtc, isTrue);
|
||||
expect(samples.first.timestamp.year, 2026);
|
||||
expect(samples.last.value, closeTo(81.5 * kgToLbs, 1e-9));
|
||||
});
|
||||
|
||||
test('passes pound samples through and sorts newest first', () {
|
||||
final samples = googleHealthWeightMetrics(
|
||||
{
|
||||
'dataPoints': [
|
||||
{
|
||||
'weight': {'unit': 'lb', 'value': 175.4},
|
||||
'instantaneousTime': '2026-10-10T06:00:00Z',
|
||||
},
|
||||
{
|
||||
'weight': {'unit': 'lb', 'value': 178.0},
|
||||
'instantaneousTime': '2026-10-12T08:30:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
since: since,
|
||||
until: until,
|
||||
);
|
||||
|
||||
expect(samples, hasLength(2));
|
||||
expect(samples.first.value, 178.0); // newest first
|
||||
expect(samples.last.value, 175.4);
|
||||
});
|
||||
|
||||
test('tolerates malformed payloads without throwing', () {
|
||||
expect(
|
||||
googleHealthWeightMetrics(null, since: since, until: until),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
googleHealthWeightMetrics(
|
||||
{'dataPoints': 'nope'},
|
||||
since: since,
|
||||
until: until,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('googleHealthActiveEnergyMetrics', () {
|
||||
final since = DateTime(2026, 10, 17);
|
||||
final until = DateTime(2026, 10, 18);
|
||||
|
||||
test('passes kcal values through', () {
|
||||
final samples = googleHealthActiveEnergyMetrics(
|
||||
{
|
||||
'dataPoints': [
|
||||
{
|
||||
'activeEnergyBurned': {'unit': 'kcal', 'value': 420.5},
|
||||
'instantaneousTime': '2026-10-17T09:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
since: since,
|
||||
until: until,
|
||||
);
|
||||
|
||||
expect(samples.single.value, 420.5);
|
||||
expect(samples.single.unit, 'kcal');
|
||||
});
|
||||
|
||||
test('returns nothing for missing data or out-of-window samples', () {
|
||||
expect(
|
||||
googleHealthActiveEnergyMetrics(null, since: since, until: until),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
googleHealthActiveEnergyMetrics(
|
||||
{
|
||||
'dataPoints': [
|
||||
{
|
||||
'activeEnergyBurned': {'unit': 'kcal', 'value': 1},
|
||||
'instantaneousTime': '2026-01-01T09:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
since: since,
|
||||
until: until,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -122,13 +122,15 @@ void main() {
|
|||
expect(find.text('Health Integrations'), findsOneWidget);
|
||||
expect(find.text('Apple Health'), findsOneWidget);
|
||||
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
|
||||
expect(find.text('Google Health'), 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));
|
||||
// Native providers and the (web-stubbed) Google Health connector are
|
||||
// unsupported on desktop; the FHIR connector is not provisioned yet —
|
||||
// every Connect affordance is disabled.
|
||||
expect(find.text('Unsupported'), findsNWidgets(3));
|
||||
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
|
||||
expect(connectButtons, findsNWidgets(3));
|
||||
expect(connectButtons, findsNWidgets(4));
|
||||
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
|
||||
expect(button.onPressed, isNull);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue