Compare commits

..

No commits in common. "17b81f567dfa600dccf9eae3240093da4462a270" and "298cadef322e736be968a9e45488ab8661bdfb96" have entirely different histories.

18 changed files with 59 additions and 1237 deletions

View File

@ -130,26 +130,6 @@
- [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.

View File

@ -217,137 +217,6 @@ 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:

View File

@ -1,41 +0,0 @@
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,
});
}

View File

@ -1,10 +0,0 @@
// 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';

View File

@ -1,428 +0,0 @@
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('=', '');
}

View File

@ -1,41 +0,0 @@
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 [];
}

View File

@ -1,110 +0,0 @@
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;
}

View File

@ -1,6 +1,5 @@
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;
@ -27,12 +26,6 @@ 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
@ -140,17 +133,6 @@ 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;
@ -274,11 +256,5 @@ 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,
List<ICloudHealthConnector> cloudConnectors = const [],
}) =>
impl.makeHealthGateway(
localReader: localReader,
cloudConnectors: cloudConnectors,
);
IHealthGateway createHealthGateway({ILocalBiometricReader? localReader}) =>
impl.makeHealthGateway(localReader: localReader);

View File

@ -1,21 +1,13 @@
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. [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,
);
/// reads serve locally logged data instead of nothing.
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
FallbackHealthGateway(localBiometricReader: localReader);
/// Graceful-degradation gateway for Web, Windows, macOS, and Linux
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
@ -33,14 +25,8 @@ IHealthGateway makeHealthGateway({
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
/// open the manual-entry dialog.
class FallbackHealthGateway extends IHealthGateway {
FallbackHealthGateway({
ILocalBiometricReader? localBiometricReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) : _localReader = localBiometricReader,
_cloudConnectors = {
for (final connector in cloudConnectors)
connector.providerType: connector,
} {
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
: _localReader = localBiometricReader {
// Debug-only diagnostic: makes the active driver obvious while
// troubleshooting platform issues; `debugPrint` is stripped in release.
debugPrint(
@ -52,85 +38,28 @@ 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,
}) 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));
}
}) =>
_read(() => _localReader?.readWeightRange(start: start, end: end));
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime 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));
}
}) =>
_read(() => _localReader?.readEnergyRange(start: start, end: end));
@override
Future<bool> writeWeight({
@ -159,21 +88,4 @@ 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 [];
}
}
}

View File

@ -3,23 +3,15 @@ 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`.
///
/// 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);
/// [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();
/// `package:health`-backed gateway for Android (Health Connect) and iOS
/// (HealthKit).
@ -43,34 +35,9 @@ IHealthGateway makeHealthGateway({
/// 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;
@ -116,25 +83,6 @@ 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();
@ -219,10 +167,6 @@ 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;
}
@ -255,8 +199,6 @@ 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();
@ -264,21 +206,18 @@ class MobileHealthGateway extends IHealthGateway {
@override
Future<HealthDataPayload?> fetchLatestBiometrics() async {
if (!supportsNativeHealth && !supportsCloudSync) return null;
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);
// Blood glucose is native-only; the cloud connectors don't expose it.
final glucose = supportsNativeHealth
? await _fetch(
final glucose = 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) {
@ -295,11 +234,6 @@ 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
@ -315,43 +249,27 @@ class MobileHealthGateway extends IHealthGateway {
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async {
final merged = [
...await _fetch(
}) =>
_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,
}) async {
final merged = [
...await _fetch(
}) =>
_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({

View File

@ -2,7 +2,6 @@ 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';
@ -17,13 +16,11 @@ final healthSyncRepositoryProvider = Provider<IHealthSyncRepository>(
);
/// Cross-platform health boundary (HealthKit / Health Connect on mobile,
/// manual-entry fallback + cloud OAuth connectors (Google Health) on
/// Web/Windows/macOS).
/// manual-entry fallback on Web/Windows/macOS).
///
/// The fallback driver receives the encrypted drift cache as its
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
/// 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).
/// biometrics instead of returning nothing.
///
/// Overridable in tests with a fake gateway.
final healthGatewayProvider = Provider<IHealthGateway>(
@ -31,7 +28,6 @@ final healthGatewayProvider = Provider<IHealthGateway>(
localReader: DriftLocalBiometricReader(
ref.watch(healthSyncRepositoryProvider),
),
cloudConnectors: [createGoogleHealthConnector()],
),
);
@ -214,17 +210,16 @@ class BiometricController extends AsyncNotifier<BiometricState> {
}
/// Imports weight and active-energy samples from the native health store
/// (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).
/// over the trailing [window] and persists them idempotently (the previous
/// import for the same source/window is replaced, not duplicated).
///
/// 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
/// 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
/// [BiometricState.statusMessage].
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
final gateway = ref.read(healthGatewayProvider);
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
if (!gateway.supportsNativeHealth) {
await _refresh(
statusMessage:
'Native health sync is unavailable here - use manual entry.',
@ -244,9 +239,6 @@ class BiometricController extends AsyncNotifier<BiometricState> {
);
try {
// 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(
@ -255,7 +247,6 @@ class BiometricController extends AsyncNotifier<BiometricState> {
);
return false;
}
}
final now = DateTime.now();
final start = now.subtract(window);

View File

@ -289,9 +289,7 @@ class _WeightTrackerCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final biometric = ref.watch(biometricControllerProvider);
final gateway = ref.watch(healthGatewayProvider);
final supported =
gateway.supportsNativeHealth || gateway.supportsCloudSync;
final supported = ref.watch(healthGatewayProvider).supportsNativeHealth;
// Surface sync/manual outcomes as snackbars whenever the status changes.
ref.listen(biometricControllerProvider, (previous, next) {
@ -455,28 +453,15 @@ class _WeightTrackerCard extends ConsumerWidget {
);
}
/// 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).
/// Native platforms run the health sync; Web/Windows/macOS open the manual
/// entry dialog (the gateway reports `supportsNativeHealth == false`).
Future<void> _logWeightOrSync(BuildContext context, WidgetRef ref) async {
final gateway = ref.read(healthGatewayProvider);
final canSync =
gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
if (!canSync) {
final supported = ref.read(healthGatewayProvider).supportsNativeHealth;
if (!supported) {
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)));
}
}
}

View File

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -17,7 +15,6 @@ class IntegrationsState {
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
this.fhirStatus = HealthConnectionStatus.notConfigured,
this.googleHealthStatus = HealthConnectionStatus.notConfigured,
this.isRequestingPermissions = false,
this.errorMessage,
});
@ -35,9 +32,6 @@ 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;
@ -52,10 +46,6 @@ 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 =>
@ -66,7 +56,6 @@ class IntegrationsState {
HealthConnectionStatus? appleHealthStatus,
HealthConnectionStatus? healthConnectStatus,
HealthConnectionStatus? fhirStatus,
HealthConnectionStatus? googleHealthStatus,
bool? isRequestingPermissions,
Object? errorMessage = _keep,
}) =>
@ -74,7 +63,6 @@ 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)
@ -127,13 +115,10 @@ 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,
);
}
@ -146,17 +131,6 @@ 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
@ -233,8 +207,6 @@ 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;
}

View File

@ -322,11 +322,7 @@ class _IntegrationsSection extends ConsumerWidget {
_IntegrationRow(
icon: Icons.apple,
title: 'Apple Health',
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)',
subtitle: 'Weight, active energy & blood glucose (HealthKit)',
status: state.appleHealthStatus,
busy: state.isRequestingPermissions,
onConnect: state.isRequestingPermissions
@ -341,11 +337,8 @@ class _IntegrationsSection extends ConsumerWidget {
_IntegrationRow(
icon: Icons.fingerprint,
title: 'Health Connect (Samsung & Pixel)',
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',
subtitle:
'Federated biometrics from Samsung Health & Pixel sensors',
status: state.healthConnectStatus,
busy: state.isRequestingPermissions,
onConnect: state.isRequestingPermissions
@ -357,21 +350,6 @@ 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',

View File

@ -194,7 +194,7 @@ packages:
source: hosted
version: "3.1.2"
crypto:
dependency: "direct main"
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@ -1075,7 +1075,7 @@ packages:
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
dependency: transitive
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda

View File

@ -38,11 +38,9 @@ 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

View File

@ -1,125 +0,0 @@
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,
);
});
});
}

View File

@ -122,15 +122,13 @@ 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 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));
// 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(4));
expect(connectButtons, findsNWidgets(3));
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
expect(button.onPressed, isNull);
}