diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 67ff6c3..8acb5c0 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -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. diff --git a/lib/core/platform/cloud_health/cloud_health_connector.dart b/lib/core/platform/cloud_health/cloud_health_connector.dart new file mode 100644 index 0000000..41fda65 --- /dev/null +++ b/lib/core/platform/cloud_health/cloud_health_connector.dart @@ -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 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 authorize(); + + /// Clears stored tokens (local revocation; the provider-side grant stays + /// until revoked in the partner's own settings). + Future disconnect(); + + /// Weight samples in pounds for `[start, end]`, newest first. + Future> fetchWeightHistory({ + required DateTime start, + required DateTime end, + }); + + /// Active energy burned (kcal) samples for `[start, end]`, newest first. + Future> fetchActiveEnergyBurned({ + required DateTime start, + required DateTime end, + }); +} \ No newline at end of file diff --git a/lib/core/platform/cloud_health/google_health_connector.dart b/lib/core/platform/cloud_health/google_health_connector.dart new file mode 100644 index 0000000..d1215d3 --- /dev/null +++ b/lib/core/platform/cloud_health/google_health_connector.dart @@ -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'; \ No newline at end of file diff --git a/lib/core/platform/cloud_health/google_health_connector_io.dart b/lib/core/platform/cloud_health/google_health_connector_io.dart new file mode 100644 index 0000000..3d54b28 --- /dev/null +++ b/lib/core/platform/cloud_health/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 toJson() => { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_at': expiresAt.millisecondsSinceEpoch, + }; + + static GoogleHealthTokens? tryParse(Object? raw) { + if (raw is! Map) 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:/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 checkStatus() async { + if (!_isConfigured) return HealthConnectionStatus.notConfigured; + final tokens = await _readTokens(); + return tokens == null + ? HealthConnectionStatus.notConfigured + : HealthConnectionStatus.connected; + } + + @override + Future 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 disconnect() async { + await _storage.delete(key: tokenStorageKey); + } + + @override + Future> 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> 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> _fetchMetrics({ + required String dataType, + required DateTime start, + required DateTime end, + required List Function(Object? payload) parse, + }) async { + final tokens = await _validTokens(); + final samples = []; + 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 ? 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 _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 _awaitCallbackCode(HttpServer server) { + final completer = Completer(); + 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 + ? '

Google Health sign-in failed

' + '

${params['error_description'] ?? error}

' + '

You can close this window.

' + : '

GLP-1 Companion

' + '

Google Health connected. Close this window and return ' + 'to the app.

'); + 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 _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 _writeTokens(GoogleHealthTokens tokens) => + _storage.write(key: tokenStorageKey, value: jsonEncode(tokens.toJson())); + + /// Returns a valid access token, transparently refreshing an expired one. + Future _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 _requestTokens(Map 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; + 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 _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.generate(byteCount, (_) => random.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } + + static String _codeChallengeS256(String verifier) => + base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes) + .replaceAll('=', ''); +} \ No newline at end of file diff --git a/lib/core/platform/cloud_health/google_health_connector_stub.dart b/lib/core/platform/cloud_health/google_health_connector_stub.dart new file mode 100644 index 0000000..e9725d9 --- /dev/null +++ b/lib/core/platform/cloud_health/google_health_connector_stub.dart @@ -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 checkStatus() async => + HealthConnectionStatus.unsupportedPlatform; + + @override + Future authorize() async => false; + + @override + Future disconnect() async {} + + @override + Future> fetchWeightHistory({ + required DateTime start, + required DateTime end, + }) async => + const []; + + @override + Future> fetchActiveEnergyBurned({ + required DateTime start, + required DateTime end, + }) async => + const []; +} \ No newline at end of file diff --git a/lib/core/platform/cloud_health/google_health_parser.dart b/lib/core/platform/cloud_health/google_health_parser.dart new file mode 100644 index 0000000..6a976d4 --- /dev/null +++ b/lib/core/platform/cloud_health/google_health_parser.dart @@ -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": }`) 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 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) 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 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) 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 _metricsFromDataPoints( + Object? payload, { + required DateTime since, + required DateTime until, + required HealthMetric? Function(Map point) parse, +}) { + if (payload is! Map) return const []; + final dataPoints = payload['dataPoints']; + if (dataPoints is! List) return const []; + + final samples = []; + for (final raw in dataPoints) { + if (raw is! Map) 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 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) { + final start = interval['startTime']; + if (start is String) return DateTime.tryParse(start); + } + return null; +} \ No newline at end of file diff --git a/lib/core/platform/health_gateway.dart b/lib/core/platform/health_gateway.dart index e670c63..cd2353b 100644 --- a/lib/core/platform/health_gateway.dart +++ b/lib/core/platform/health_gateway.dart @@ -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 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 cloudConnectors = const [], +}) => + impl.makeHealthGateway( + localReader: localReader, + cloudConnectors: cloudConnectors, + ); diff --git a/lib/core/platform/health_gateway_fallback.dart b/lib/core/platform/health_gateway_fallback.dart index 94fa5cb..36a030e 100644 --- a/lib/core/platform/health_gateway_fallback.dart +++ b/lib/core/platform/health_gateway_fallback.dart @@ -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 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 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 _cloudConnectors; + @override bool get supportsNativeHealth => false; + @override + bool get supportsCloudSync => _cloudConnectors.isNotEmpty; + + @override + Future 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 requestPermissions() async => false; + @override + Future checkStatus(HealthProviderType provider) => + _cloudConnectors[provider]?.checkStatus() ?? super.checkStatus(provider); + + @override + Future requestAuthorization(HealthProviderType provider) { + final connector = _cloudConnectors[provider]; + if (connector != null) return connector.authorize(); + return super.requestAuthorization(provider); + } + + @override + Future disconnect(HealthProviderType provider) async { + final connector = _cloudConnectors[provider]; + if (connector != null) { + await connector.disconnect(); + return; + } + await super.disconnect(provider); + } + @override Future> 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> 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 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> _cloudRead( + Future> Function() read, + ) async { + try { + return await read(); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'HealthGateway: cloud connector read failed (non-fatal)', + ); + return const []; + } + } } \ No newline at end of file diff --git a/lib/core/platform/health_gateway_mobile.dart b/lib/core/platform/health_gateway_mobile.dart index 5d87907..97589ed 100644 --- a/lib/core/platform/health_gateway_mobile.dart +++ b/lib/core/platform/health_gateway_mobile.dart @@ -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 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 cloudConnectors = const [], + }) : _cloudConnectors = { + for (final connector in cloudConnectors) + connector.providerType: connector, + }; + final Health _health = Health(); Future? _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 _cloudConnectors; + + @override + bool get supportsCloudSync => _cloudConnectors.isNotEmpty; + + @override + Future 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? _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> _cloudFetch( + Iterable>> 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 _configure() => _configuration ??= _health.configure(); @@ -167,6 +219,10 @@ class MobileHealthGateway extends IHealthGateway { Future 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 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 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 []; 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 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> 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> 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 writeWeight({ diff --git a/lib/features/health_sync/presentation/controllers/biometric_controller.dart b/lib/features/health_sync/presentation/controllers/biometric_controller.dart index 78e3807..1f5b616 100644 --- a/lib/features/health_sync/presentation/controllers/biometric_controller.dart +++ b/lib/features/health_sync/presentation/controllers/biometric_controller.dart @@ -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( ); /// 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( @@ -28,6 +31,7 @@ final healthGatewayProvider = Provider( localReader: DriftLocalBiometricReader( ref.watch(healthSyncRepositoryProvider), ), + cloudConnectors: [createGoogleHealthConnector()], ), ); @@ -210,16 +214,17 @@ class BiometricController extends AsyncNotifier { } /// 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 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 { ); 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(); diff --git a/lib/features/nutrition/presentation/screens/nutrition_screen.dart b/lib/features/nutrition/presentation/screens/nutrition_screen.dart index 62a346c..53e0c72 100644 --- a/lib/features/nutrition/presentation/screens/nutrition_screen.dart +++ b/lib/features/nutrition/presentation/screens/nutrition_screen.dart @@ -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 _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))); + } } } diff --git a/lib/features/settings/presentation/controllers/integrations_controller.dart b/lib/features/settings/presentation/controllers/integrations_controller.dart index 57a024e..8789ff0 100644 --- a/lib/features/settings/presentation/controllers/integrations_controller.dart +++ b/lib/features/settings/presentation/controllers/integrations_controller.dart @@ -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 { 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 { Future 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 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 { 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; } diff --git a/lib/features/settings/presentation/screens/settings_screen.dart b/lib/features/settings/presentation/screens/settings_screen.dart index 01fc001..dc112ed 100644 --- a/lib/features/settings/presentation/screens/settings_screen.dart +++ b/lib/features/settings/presentation/screens/settings_screen.dart @@ -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', diff --git a/pubspec.lock b/pubspec.lock index f48b15b..61158ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index c3c7ec4..02d50ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/core/platform/google_health_parser_test.dart b/test/core/platform/google_health_parser_test.dart new file mode 100644 index 0000000..b62f538 --- /dev/null +++ b/test/core/platform/google_health_parser_test.dart @@ -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, + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/settings/settings_screen_test.dart b/test/features/settings/settings_screen_test.dart index 0facf6a..a69b2ac 100644 --- a/test/features/settings/settings_screen_test.dart +++ b/test/features/settings/settings_screen_test.dart @@ -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(connectButtons)) { expect(button.onPressed, isNull); }