feat(platform,settings,nutrition): add Google Health cloud sync for desktop

Cloud connector framework: ICloudHealthConnector contract, supportsCloudSync / isCloudSyncReady on IHealthGateway, and a HealthProviderType.googleHealth slot; a conditional-import shim (io implementation / web stub) keeps platform channels out of web builds.

Google Health connector (successor to the retired Fitbit Web API, shutdown 2026-10-30): OAuth2 authorization-code + PKCE through the system browser with a loopback redirect; read-only activity-and-fitness + health-metrics scopes; tokens in flutter_secure_storage with transparent refresh; paginated weight and active-energy-burned dataPoints reads with kg-to-lb normalization from the per-point unit field; pure unit-tested parsers.

Gateway wiring: both drivers store injected connectors (desktop runs MobileHealthGateway through the io conditional import; the fallback driver is web-only); cloud status/authorize/disconnect route first and reads merge native + cloud non-fatally; syncFromHealth accepts cloud-only sources and imports persist as BiometricSource.cloudSync.

UI: Google Health row in Settings with Connect/Disconnect and auto-import on connect; explanatory desktop copy on the Apple Health and Health Connect rows; the Nutrition weight card syncs through a connected cloud partner and surfaces the outcome as a snackbar, degrading to the manual dialog when no source is connected.

Field fixes recorded in BUILD_STATE: io-driver connector wiring, duplicate-implementation merge correction, weight-card sync gate. Tests: google_health_parser_test suite; settings_screen_test updated for the fourth integration row. The legacy Fitbit connector was fully removed before shipping.
This commit is contained in:
Mike Kell 2026-10-17 18:06:36 -04:00
parent 298cadef32
commit 5b5e7e2f57
17 changed files with 1106 additions and 59 deletions

View File

@ -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.

View File

@ -0,0 +1,41 @@
import '../health_gateway.dart';
/// Cloud health partner (Google Health, Withings, ...) reachable via OAuth2 + REST
/// from platforms that lack a native health SDK (architecture rule 3:
/// desktop/web third-party OAuth REST sync).
///
/// Implementations normalize everything into the gateway's display units
/// (pounds / kcal) so callers never deal with provider units. Declared in
/// `core` so features and the platform layer share one contract; the
/// dependency points inward: implementations live beside the gateway and are
/// injected through `createHealthGateway`.
abstract class ICloudHealthConnector {
/// Gateway provider slot this connector fills.
HealthProviderType get providerType;
/// `connected` when a (possibly refreshed) OAuth session is stored,
/// `notConfigured` otherwise, `unsupportedPlatform` on platforms the
/// connector cannot run (e.g. Web).
Future<HealthConnectionStatus> checkStatus();
/// Runs the OAuth2 authorization-code + PKCE round trip (opens the system
/// browser, waits for the loopback redirect) and persists tokens.
/// Returns whether the user completed the flow.
Future<bool> authorize();
/// Clears stored tokens (local revocation; the provider-side grant stays
/// until revoked in the partner's own settings).
Future<void> disconnect();
/// Weight samples in pounds for `[start, end]`, newest first.
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
});
/// Active energy burned (kcal) samples for `[start, end]`, newest first.
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
});
}

View File

@ -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';

View File

@ -0,0 +1,428 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import '../health_gateway.dart';
import 'cloud_health_connector.dart';
import 'google_health_parser.dart';
/// Conditional-import entry point used by the gateway wiring: the real
/// connector only exists where `dart:io` does (Windows/macOS/Linux/Android/
/// iOS); Web falls back to the unsupported stub.
ICloudHealthConnector createGoogleHealthConnector() =>
GoogleHealthConnector();
/// OAuth2 token bundle persisted per Google Health account.
class GoogleHealthTokens {
const GoogleHealthTokens({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
});
final String accessToken;
final String refreshToken;
final DateTime expiresAt;
Map<String, Object?> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt.millisecondsSinceEpoch,
};
static GoogleHealthTokens? tryParse(Object? raw) {
if (raw is! Map<String, dynamic>) return null;
final access = raw['access_token'];
final refresh = raw['refresh_token'];
final expiresAt = raw['expires_at'];
if (access is! String || refresh is! String || expiresAt is! num) {
return null;
}
return GoogleHealthTokens(
accessToken: access,
refreshToken: refresh,
expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt.toInt()),
);
}
}
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
/// the system browser with a loopback redirect
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
/// transparent refresh, and normalized weight / active-energy reads from the
/// Google Health API — the official successor to the retired Fitbit Web API
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
/// Health.
///
/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
/// them in a Google Cloud project with the Google Health API enabled; the
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
class GoogleHealthConnector implements ICloudHealthConnector {
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
: _client = client ?? http.Client(),
_storage = storage ?? const FlutterSecureStorage();
/// Storage key for the persisted [GoogleHealthTokens] JSON.
static const String tokenStorageKey = 'google_health.oauth.tokens';
static const String _authorizeEndpoint =
'https://accounts.google.com/o/oauth2/v2/auth';
static const String _tokenEndpoint = 'https://oauth2.googleapis.com/token';
static const String _apiRoot = 'https://health.googleapis.com/v4';
/// Read-only scopes: steps/energy live under activity & fitness, weight
/// under health metrics (scopes page: request only what you need).
static const String _scopes =
'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly'
' https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly';
/// Data type identifiers from the Google Health data-types index.
static const String _weightDataType = 'weight';
static const String _activeEnergyDataType = 'active-energy-burned';
static const int _pageSize = 1000;
static const Duration _expiryMargin = Duration(minutes: 2);
static const Duration _callbackTimeout = Duration(minutes: 5);
final http.Client _client;
final FlutterSecureStorage _storage;
String? get _clientId => dotenv.env['GOOGLE_HEALTH_CLIENT_ID'];
String? get _clientSecret => dotenv.env['GOOGLE_HEALTH_CLIENT_SECRET'];
int get _redirectPort => int.tryParse(
dotenv.env['GOOGLE_HEALTH_REDIRECT_PORT'] ?? '',
) ??
52789;
Uri get _redirectUri => Uri.parse('http://localhost:$_redirectPort/callback');
bool get _isConfigured =>
(_clientId?.isNotEmpty ?? false) && (_clientSecret?.isNotEmpty ?? false);
@override
HealthProviderType get providerType => HealthProviderType.googleHealth;
@override
Future<HealthConnectionStatus> checkStatus() async {
if (!_isConfigured) return HealthConnectionStatus.notConfigured;
final tokens = await _readTokens();
return tokens == null
? HealthConnectionStatus.notConfigured
: HealthConnectionStatus.connected;
}
@override
Future<bool> authorize() async {
if (!_isConfigured) {
debugPrint(
'GoogleHealthConnector: GOOGLE_HEALTH_CLIENT_ID / '
'GOOGLE_HEALTH_CLIENT_SECRET missing from .env - cloud sync stays '
'unconfigured.',
);
return false;
}
final verifier = _randomUrlSafe(64);
final authorizeUrl = Uri.parse(
'$_authorizeEndpoint?response_type=code'
'&client_id=${Uri.encodeComponent(_clientId!)}'
'&redirect_uri=${Uri.encodeComponent(_redirectUri.toString())}'
'&scope=${Uri.encodeComponent(_scopes)}'
'&code_challenge=${_codeChallengeS256(verifier)}'
'&code_challenge_method=S256'
// Offline access + explicit consent keep the refresh token stable
// (Google only returns a refresh token on the first consent).
'&access_type=offline'
'&prompt=consent'
'&include_granted_scopes=true',
);
final server = await _bindLoopback();
if (server == null) return false;
try {
if (!await launchUrl(authorizeUrl, mode: LaunchMode.platformDefault)) {
return false;
}
final code = await _awaitCallbackCode(server);
final tokens = await _requestTokens({
'grant_type': 'authorization_code',
'code': code,
'code_verifier': verifier,
'redirect_uri': _redirectUri.toString(),
});
await _writeTokens(tokens);
return true;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'GoogleHealthConnector: authorization failed ($error)',
);
return false;
} finally {
await server.close(force: true);
}
}
@override
Future<void> disconnect() async {
await _storage.delete(key: tokenStorageKey);
}
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async {
try {
return await _fetchMetrics(
dataType: _weightDataType,
start: start,
end: end,
parse: (payload) => googleHealthWeightMetrics(
payload,
since: start,
until: end,
),
);
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'GoogleHealthConnector: weight fetch failed (non-fatal)',
);
return const [];
}
}
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) async {
try {
return await _fetchMetrics(
dataType: _activeEnergyDataType,
start: start,
end: end,
parse: (payload) => googleHealthActiveEnergyMetrics(
payload,
since: start,
until: end,
),
);
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'GoogleHealthConnector: energy fetch failed (non-fatal)',
);
return const [];
}
}
/// Walks every page of `users/me/dataTypes/{dataType}/dataPoints` (the API
/// caps pages at `pageSize`; `nextPageToken` chains until exhausted) and
/// merges the parsed samples, newest first. Server-side filters are
/// deliberately avoided — the connector filters client-side against
/// `[start, end]` so the parsing rules stay unit-tested and predictable.
Future<List<HealthMetric>> _fetchMetrics({
required String dataType,
required DateTime start,
required DateTime end,
required List<HealthMetric> Function(Object? payload) parse,
}) async {
final tokens = await _validTokens();
final samples = <HealthMetric>[];
String? pageToken;
do {
final query = StringBuffer('pageSize=$_pageSize');
if (pageToken != null) {
query.write('&pageToken=${Uri.encodeComponent(pageToken)}');
}
final payload = await _getJson(
tokens,
'/users/me/dataTypes/$dataType/dataPoints?$query',
);
samples.addAll(parse(payload));
pageToken =
payload is Map<String, dynamic> ? payload['nextPageToken'] as String? : null;
} while (pageToken != null && pageToken.isNotEmpty);
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return samples;
}
// ---------------------------------------------------------------------------
// Loopback redirect listener (RFC 8252 native-app pattern; Google accepts
// localhost redirect URIs registered on the OAuth client).
// ---------------------------------------------------------------------------
Future<HttpServer?> _bindLoopback() async {
try {
return await HttpServer.bind(InternetAddress.loopbackIPv4, _redirectPort);
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'GoogleHealthConnector: could not bind the loopback redirect '
'port $_redirectPort (is another app using it?)',
);
return null;
}
}
Future<String> _awaitCallbackCode(HttpServer server) {
final completer = Completer<String>();
server.listen((request) async {
final params = request.uri.queryParameters;
final error = params['error'];
final code = params['code'];
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.html
..write(error != null
? '<html><body><h3>Google Health sign-in failed</h3>'
'<p>${params['error_description'] ?? error}</p>'
'<p>You can close this window.</p></body></html>'
: '<html><body><h3>GLP-1 Companion</h3>'
'<p>Google Health connected. Close this window and return '
'to the app.</p></body></html>');
await request.response.close();
if (completer.isCompleted) return;
if (error != null || code == null) {
completer.completeError(
FormatException(
'Google Health authorization failed: '
'${params['error_description'] ?? error ?? 'no code returned'}',
),
);
} else {
completer.complete(code);
}
});
return completer.future.timeout(
_callbackTimeout,
onTimeout: () => throw TimeoutException(
'Timed out waiting for the Google Health redirect.',
),
);
}
// ---------------------------------------------------------------------------
// Token lifecycle.
// ---------------------------------------------------------------------------
Future<GoogleHealthTokens?> _readTokens() async {
try {
final raw = await _storage.read(key: tokenStorageKey);
if (raw == null) return null;
return GoogleHealthTokens.tryParse(jsonDecode(raw));
} catch (error) {
debugPrint('GoogleHealthConnector: token read failed ($error)');
return null;
}
}
Future<void> _writeTokens(GoogleHealthTokens tokens) =>
_storage.write(key: tokenStorageKey, value: jsonEncode(tokens.toJson()));
/// Returns a valid access token, transparently refreshing an expired one.
Future<GoogleHealthTokens> _validTokens() async {
final tokens = await _readTokens();
if (tokens == null) {
throw StateError('Google Health is not connected.');
}
if (DateTime.now().isBefore(tokens.expiresAt.subtract(_expiryMargin))) {
return tokens;
}
final refreshed = await _requestTokens({
'grant_type': 'refresh_token',
'refresh_token': tokens.refreshToken,
});
await _writeTokens(refreshed);
return refreshed;
}
Future<GoogleHealthTokens> _requestTokens(Map<String, String> form) async {
final response = await _client.post(
Uri.parse(_tokenEndpoint),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
...form,
'client_id': _clientId!,
'client_secret': _clientSecret!,
},
);
if (response.statusCode != HttpStatus.ok) {
throw http.ClientException(
'Google Health token request failed (${response.statusCode}): '
'${response.body}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final access = json['access_token'];
final refresh = json['refresh_token'];
if (access is! String) {
throw const FormatException(
'Google Health token response had no access_token.',
);
}
final expiresIn =
Duration(seconds: (json['expires_in'] as num?)?.toInt() ?? 3600);
return GoogleHealthTokens(
accessToken: access,
// Google omits refresh_token on some re-consent flows; keep the
// previously stored one in that case.
refreshToken: refresh is String && refresh.isNotEmpty
? refresh
: (await _readTokens())?.refreshToken ?? '',
expiresAt: DateTime.now().add(expiresIn),
);
}
// ---------------------------------------------------------------------------
// REST helpers.
// ---------------------------------------------------------------------------
Future<Object?> _getJson(GoogleHealthTokens tokens, String path) async {
Uri url() => Uri.parse('$_apiRoot$path');
var response = await _client
.get(url(), headers: {'Authorization': 'Bearer ${tokens.accessToken}'});
if (response.statusCode == HttpStatus.unauthorized) {
// Server-side invalidation: refresh once and retry.
final refreshed = await _requestTokens({
'grant_type': 'refresh_token',
'refresh_token': tokens.refreshToken,
});
await _writeTokens(refreshed);
response = await _client.get(url(),
headers: {'Authorization': 'Bearer ${refreshed.accessToken}'});
}
if (response.statusCode != HttpStatus.ok) {
throw http.ClientException(
'Google Health request failed (${response.statusCode}) for $path',
);
}
return jsonDecode(response.body);
}
// ---------------------------------------------------------------------------
// PKCE helpers.
// ---------------------------------------------------------------------------
static String _randomUrlSafe(int byteCount) {
final random = Random.secure();
final bytes = List<int>.generate(byteCount, (_) => random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
static String _codeChallengeS256(String verifier) =>
base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes)
.replaceAll('=', '');
}

View File

@ -0,0 +1,41 @@
import '../health_gateway.dart';
import 'cloud_health_connector.dart';
/// Web fallback for the Google Health connector: cloud OAuth needs a
/// loopback redirect and secure token storage, neither of which exist on
/// Web, so the connector reports `unsupportedPlatform` and performs nothing
/// (the same zero-platform-channel discipline as the rest of the fallback
/// driver).
ICloudHealthConnector createGoogleHealthConnector() =>
const _WebGoogleHealthConnector();
class _WebGoogleHealthConnector implements ICloudHealthConnector {
const _WebGoogleHealthConnector();
@override
HealthProviderType get providerType => HealthProviderType.googleHealth;
@override
Future<HealthConnectionStatus> checkStatus() async =>
HealthConnectionStatus.unsupportedPlatform;
@override
Future<bool> authorize() async => false;
@override
Future<void> disconnect() async {}
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) async =>
const [];
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) async =>
const [];
}

View File

@ -0,0 +1,110 @@
import '../health_gateway.dart';
/// Kilograms to pounds (exact conversion factor used across the app).
const double kgToLbs = 2.20462262185;
/// Parses a Google Health API `dataPoints` list response for **weight**
/// (`GET /v4/users/me/dataTypes/weight/dataPoints`) into normalized
/// [HealthMetric] samples.
///
/// Each data point carries its value under the `weight` key
/// (`{"unit": "kg" | "lb", "value": <number>}`) and its time either as
/// `instantaneousTime` or as `timeInterval.startTime` (RFC 3339). Google
/// stores weight in kilograms by default; the `unit` field drives
/// normalization into pounds. Samples outside `[since, until]` are dropped
/// (the connector does not rely on server-side filters) and the result is
/// newest-first.
List<HealthMetric> googleHealthWeightMetrics(
Object? payload, {
required DateTime since,
required DateTime until,
}) =>
_metricsFromDataPoints(
payload,
since: since,
until: until,
parse: (point) {
final weight = point['weight'];
if (weight is! Map<String, dynamic>) return null;
final value = weight['value'];
if (value is! num) return null;
final isPounds = weight['unit'] == 'lb';
return HealthMetric(
timestamp: DateTime.now(), // replaced by the caller
value: isPounds ? value.toDouble() : value.toDouble() * kgToLbs,
unit: 'lb',
);
},
);
/// Parses a Google Health API `dataPoints` list response for **active
/// energy** (`GET /v4/users/me/dataTypes/active-energy-burned/dataPoints`)
/// into normalized kcal [HealthMetric] samples (values pass through — the
/// API reports energy in kcal).
List<HealthMetric> googleHealthActiveEnergyMetrics(
Object? payload, {
required DateTime since,
required DateTime until,
}) =>
_metricsFromDataPoints(
payload,
since: since,
until: until,
parse: (point) {
final energy = point['activeEnergyBurned'];
if (energy is! Map<String, dynamic>) return null;
final value = energy['value'];
if (value is! num) return null;
return HealthMetric(
timestamp: DateTime.now(), // replaced by the caller
value: value.toDouble(),
unit: 'kcal',
);
},
);
/// Shared dataPoints-list walk: extracts the timestamp (preferring
/// `instantaneousTime`, falling back to `timeInterval.startTime`), applies
/// [parse] to the typed payload, and assembles newest-first samples inside
/// `[since, until]`.
List<HealthMetric> _metricsFromDataPoints(
Object? payload, {
required DateTime since,
required DateTime until,
required HealthMetric? Function(Map<String, dynamic> point) parse,
}) {
if (payload is! Map<String, dynamic>) return const [];
final dataPoints = payload['dataPoints'];
if (dataPoints is! List) return const [];
final samples = <HealthMetric>[];
for (final raw in dataPoints) {
if (raw is! Map<String, dynamic>) continue;
final timestamp = _timestampOf(raw);
if (timestamp == null) continue;
if (timestamp.isBefore(since) || timestamp.isAfter(until)) continue;
final parsed = parse(raw);
if (parsed == null) continue;
samples.add(
HealthMetric(timestamp: timestamp, value: parsed.value, unit: parsed.unit),
);
}
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return samples;
}
/// Extracts the sample time from a data point: `instantaneousTime` first
/// (Sample records), then `timeInterval.startTime` (Interval/Daily).
DateTime? _timestampOf(Map<String, dynamic> point) {
final instant = point['instantaneousTime'];
if (instant is String) {
final parsed = DateTime.tryParse(instant);
if (parsed != null) return parsed;
}
final interval = point['timeInterval'];
if (interval is Map<String, dynamic>) {
final start = interval['startTime'];
if (start is String) return DateTime.tryParse(start);
}
return null;
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'cloud_health/cloud_health_connector.dart';
import 'health_gateway_fallback.dart'
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
@ -26,6 +27,12 @@ enum HealthProviderType {
samsungHealth,
enterpriseFhir,
manualFallback,
/// Google Health cloud sync (OAuth2 + REST) — the official successor to
/// the retired Fitbit Web API (shutdown 2026-10-30); carries Fitbit,
/// Pixel and Wear OS data. Wired through the cloud-connector framework
/// into the fallback driver for desktop/web.
googleHealth,
}
/// Entitlement / read-permission state reported by
@ -133,6 +140,17 @@ abstract class IHealthGateway {
/// Web/Windows/macOS, which signals the UI to offer manual entry.
bool get supportsNativeHealth;
/// Whether a cloud OAuth partner (Google Health, ...) is wired into this driver
/// and can back a sync even without a native health SDK. `false` by
/// default; the fallback driver flips it when cloud connectors are
/// injected.
bool get supportsCloudSync => false;
/// Whether any injected cloud partner currently holds a valid grant, i.e.
/// a cloud-backed sync would return partner data. `false` by default;
/// drivers with connectors override by probing each connector's status.
Future<bool> isCloudSyncReady() async => false;
/// Ecosystem backing [supportsNativeHealth], or `null` in fallback mode.
NativeHealthPlatform? get nativePlatform;
@ -256,5 +274,11 @@ abstract class IHealthGateway {
///
/// [localReader] backs fallback reads with the locally logged biometric
/// cache; mobile platforms ignore it and query the native store directly.
IHealthGateway createHealthGateway({ILocalBiometricReader? localReader}) =>
impl.makeHealthGateway(localReader: localReader);
IHealthGateway createHealthGateway({
ILocalBiometricReader? localReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) =>
impl.makeHealthGateway(
localReader: localReader,
cloudConnectors: cloudConnectors,
);

View File

@ -1,13 +1,21 @@
import 'package:flutter/foundation.dart';
import 'cloud_health/cloud_health_connector.dart';
import 'health_gateway.dart';
/// Conditional-import entry point used by `health_gateway.dart`.
///
/// [localReader] (usually the drift-backed biometric cache) lets fallback
/// reads serve locally logged data instead of nothing.
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
FallbackHealthGateway(localBiometricReader: localReader);
/// reads serve locally logged data instead of nothing. [cloudConnectors]
/// add third-party OAuth REST sync (Google Health, ...) on desktop/web.
IHealthGateway makeHealthGateway({
ILocalBiometricReader? localReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) =>
FallbackHealthGateway(
localBiometricReader: localReader,
cloudConnectors: cloudConnectors,
);
/// Graceful-degradation gateway for Web, Windows, macOS, and Linux
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
@ -25,8 +33,14 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
/// open the manual-entry dialog.
class FallbackHealthGateway extends IHealthGateway {
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
: _localReader = localBiometricReader {
FallbackHealthGateway({
ILocalBiometricReader? localBiometricReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) : _localReader = localBiometricReader,
_cloudConnectors = {
for (final connector in cloudConnectors)
connector.providerType: connector,
} {
// Debug-only diagnostic: makes the active driver obvious while
// troubleshooting platform issues; `debugPrint` is stripped in release.
debugPrint(
@ -38,28 +52,85 @@ class FallbackHealthGateway extends IHealthGateway {
final ILocalBiometricReader? _localReader;
/// Cloud partners (Google Health, ...) keyed by the provider slot they fill.
final Map<HealthProviderType, ICloudHealthConnector> _cloudConnectors;
@override
bool get supportsNativeHealth => false;
@override
bool get supportsCloudSync => _cloudConnectors.isNotEmpty;
@override
Future<bool> isCloudSyncReady() async {
for (final connector in _cloudConnectors.values) {
if (await connector.checkStatus() == HealthConnectionStatus.connected) {
return true;
}
}
return false;
}
@override
NativeHealthPlatform? get nativePlatform => null;
@override
Future<bool> requestPermissions() async => false;
@override
Future<HealthConnectionStatus> checkStatus(HealthProviderType provider) =>
_cloudConnectors[provider]?.checkStatus() ?? super.checkStatus(provider);
@override
Future<bool> requestAuthorization(HealthProviderType provider) {
final connector = _cloudConnectors[provider];
if (connector != null) return connector.authorize();
return super.requestAuthorization(provider);
}
@override
Future<void> disconnect(HealthProviderType provider) async {
final connector = _cloudConnectors[provider];
if (connector != null) {
await connector.disconnect();
return;
}
await super.disconnect(provider);
}
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) =>
_read(() => _localReader?.readWeightRange(start: start, end: end));
}) async {
final local =
await _read(() => _localReader?.readWeightRange(start: start, end: end));
final merged = [...local];
for (final connector in _cloudConnectors.values) {
merged.addAll(
await _cloudRead(() => connector.fetchWeightHistory(start: start, end: end)),
);
}
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
}
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) =>
_read(() => _localReader?.readEnergyRange(start: start, end: end));
}) async {
final local =
await _read(() => _localReader?.readEnergyRange(start: start, end: end));
final merged = [...local];
for (final connector in _cloudConnectors.values) {
merged.addAll(
await _cloudRead(
() => connector.fetchActiveEnergyBurned(start: start, end: end),
),
);
}
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
}
@override
Future<bool> writeWeight({
@ -88,4 +159,21 @@ class FallbackHealthGateway extends IHealthGateway {
return const [];
}
}
/// Cloud connector read that degrades to an empty result (never throws)
/// when the partner API errors, so a provider outage can never break the
/// local/manual data path.
Future<List<HealthMetric>> _cloudRead(
Future<List<HealthMetric>> Function() read,
) async {
try {
return await read();
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'HealthGateway: cloud connector read failed (non-fatal)',
);
return const [];
}
}
}

View File

@ -3,15 +3,23 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:health/health.dart';
import 'cloud_health/cloud_health_connector.dart';
import 'health_gateway.dart';
/// Conditional-import entry point used by `health_gateway.dart`.
///
/// [localReader] is accepted for factory-signature parity with the fallback
/// driver; native platforms never need it because reads are served by
/// Health Connect / HealthKit directly.
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
MobileHealthGateway();
/// This io-platform driver runs on Android, iOS, **and desktop** (the
/// `dart.library.io` conditional import routes every non-Web platform here;
/// the fallback driver is Web-only). [cloudConnectors] are therefore stored,
/// not discarded: desktop builds rely on them as their only sync source,
/// mobile builds gain them alongside the native store. [localReader] is kept
/// for signature parity — io platforms serve reads from Health Connect /
/// HealthKit directly.
IHealthGateway makeHealthGateway({
ILocalBiometricReader? localReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) =>
MobileHealthGateway(cloudConnectors: cloudConnectors);
/// `package:health`-backed gateway for Android (Health Connect) and iOS
/// (HealthKit).
@ -35,9 +43,34 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
/// HealthKit entitlements or hardware quirks can never surface as
/// unhandled exceptions.
class MobileHealthGateway extends IHealthGateway {
MobileHealthGateway({
List<ICloudHealthConnector> cloudConnectors = const [],
}) : _cloudConnectors = {
for (final connector in cloudConnectors)
connector.providerType: connector,
};
final Health _health = Health();
Future<void>? _configuration;
/// Cloud partners (Google Health, ...) keyed by the provider slot they
/// fill; this is desktop's only sync source and an optional supplement on
/// mobile.
final Map<HealthProviderType, ICloudHealthConnector> _cloudConnectors;
@override
bool get supportsCloudSync => _cloudConnectors.isNotEmpty;
@override
Future<bool> isCloudSyncReady() async {
for (final connector in _cloudConnectors.values) {
if (await connector.checkStatus() == HealthConnectionStatus.connected) {
return true;
}
}
return false;
}
/// Health Connect SDK availability, probed once per gateway instance.
Future<bool>? _healthConnectReady;
@ -83,6 +116,25 @@ class MobileHealthGateway extends IHealthGateway {
: NativeHealthPlatform.healthConnect;
}
/// Awaits cloud connector reads concurrently and degrades to an empty
/// result (never throws) when the partner API errors, so a provider
/// outage can never break the native/manual data path.
Future<List<HealthMetric>> _cloudFetch(
Iterable<Future<List<HealthMetric>>> pending,
) async {
if (pending.isEmpty) return const [];
try {
final results = await Future.wait(pending);
return [for (final batch in results) ...batch];
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'HealthGateway: cloud connector read failed (non-fatal)',
);
return const [];
}
}
/// `Health.configure()` resolves device info and must run exactly once
/// before any read/write call.
Future<void> _configure() => _configuration ??= _health.configure();
@ -167,6 +219,10 @@ class MobileHealthGateway extends IHealthGateway {
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider,
) async {
// Cloud partners (Google Health) manage their own status; on desktop
// they are the only supported sync source.
final cloudConnector = _cloudConnectors[provider];
if (cloudConnector != null) return cloudConnector.checkStatus();
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
@ -199,6 +255,8 @@ class MobileHealthGateway extends IHealthGateway {
@override
Future<bool> requestAuthorization(HealthProviderType provider) async {
final cloudConnector = _cloudConnectors[provider];
if (cloudConnector != null) return cloudConnector.authorize();
if (provider == HealthProviderType.manualFallback) return true;
if (!_providerMatches(provider)) return false;
return requestPermissions();
@ -206,18 +264,21 @@ class MobileHealthGateway extends IHealthGateway {
@override
Future<HealthDataPayload?> fetchLatestBiometrics() async {
if (!supportsNativeHealth) return null;
if (!supportsNativeHealth && !supportsCloudSync) return null;
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day);
final weights = await fetchWeightHistory(start: start, end: now);
final energies = await fetchActiveEnergyBurned(start: start, end: now);
final glucose = await _fetch(
type: HealthDataType.BLOOD_GLUCOSE,
start: start,
end: now,
normalize: _glucoseInMgDl,
unit: 'mg/dL',
);
// Blood glucose is native-only; the cloud connectors don't expose it.
final glucose = supportsNativeHealth
? await _fetch(
type: HealthDataType.BLOOD_GLUCOSE,
start: start,
end: now,
normalize: _glucoseInMgDl,
unit: 'mg/dL',
)
: const <HealthMetric>[];
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
var energyTotal = 0.0;
for (final sample in energies) {
@ -234,6 +295,11 @@ class MobileHealthGateway extends IHealthGateway {
@override
Future<void> disconnect(HealthProviderType provider) async {
final cloudConnector = _cloudConnectors[provider];
if (cloudConnector != null) {
await cloudConnector.disconnect();
return;
}
if (!_providerMatches(provider)) return;
// Neither HealthKit nor Health Connect exposes programmatic permission
// revocation to third-party apps; clearing the cached SDK probe forces
@ -249,27 +315,43 @@ class MobileHealthGateway extends IHealthGateway {
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
}) =>
_fetch(
}) async {
final merged = [
...await _fetch(
type: HealthDataType.WEIGHT,
start: start,
end: end,
normalize: _weightInLbs,
unit: 'lb',
);
),
...await _cloudFetch(
_cloudConnectors.values
.map((c) => c.fetchWeightHistory(start: start, end: end)),
),
];
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
}
@override
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
}) =>
_fetch(
}) async {
final merged = [
...await _fetch(
type: HealthDataType.ACTIVE_ENERGY_BURNED,
start: start,
end: end,
normalize: _energyInKcal,
unit: 'kcal',
);
),
...await _cloudFetch(
_cloudConnectors.values
.map((c) => c.fetchActiveEnergyBurned(start: start, end: end)),
),
];
return merged..sort((a, b) => b.timestamp.compareTo(a.timestamp));
}
@override
Future<bool> writeWeight({

View File

@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/database/app_database.dart';
import '../../../../core/platform/cloud_health/google_health_connector.dart';
import '../../../../core/platform/health_gateway.dart';
import '../../../../core/utils/date_time_utils.dart';
import '../../data/repositories/health_sync_repository.dart';
@ -16,11 +17,13 @@ final healthSyncRepositoryProvider = Provider<IHealthSyncRepository>(
);
/// Cross-platform health boundary (HealthKit / Health Connect on mobile,
/// manual-entry fallback on Web/Windows/macOS).
/// manual-entry fallback + cloud OAuth connectors (Google Health) on
/// Web/Windows/macOS).
///
/// The fallback driver receives the encrypted drift cache as its
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
/// biometrics instead of returning nothing.
/// biometrics, and the Google Health cloud connector so desktop/web can also
/// sync from a third-party OAuth partner (Fitbit, Pixel and Wear OS data).
///
/// Overridable in tests with a fake gateway.
final healthGatewayProvider = Provider<IHealthGateway>(
@ -28,6 +31,7 @@ final healthGatewayProvider = Provider<IHealthGateway>(
localReader: DriftLocalBiometricReader(
ref.watch(healthSyncRepositoryProvider),
),
cloudConnectors: [createGoogleHealthConnector()],
),
);
@ -210,16 +214,17 @@ class BiometricController extends AsyncNotifier<BiometricState> {
}
/// Imports weight and active-energy samples from the native health store
/// over the trailing [window] and persists them idempotently (the previous
/// import for the same source/window is replaced, not duplicated).
/// (or a connected cloud partner such as Fitbit) over the trailing
/// [window] and persists them idempotently (the previous import for the
/// same source/window is replaced, not duplicated).
///
/// Returns `false` when the platform is unsupported (Web/Windows/macOS —
/// callers should open the manual-entry dialog instead), when permission
/// is denied, or when the sync throws; the reason is reflected in
/// Returns `false` when no sync source exists on this platform (Web with
/// no cloud connector provisioned), when permission is denied, or when
/// the sync throws; the reason is reflected in
/// [BiometricState.statusMessage].
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
final gateway = ref.read(healthGatewayProvider);
if (!gateway.supportsNativeHealth) {
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
await _refresh(
statusMessage:
'Native health sync is unavailable here - use manual entry.',
@ -239,13 +244,17 @@ class BiometricController extends AsyncNotifier<BiometricState> {
);
try {
final granted = await gateway.requestPermissions();
if (!granted) {
await _refresh(
statusMessage: 'Health permissions were denied.',
error: StateError('Health permissions denied'),
);
return false;
// Native stores need an OS permission grant first; cloud connectors
// manage their own OAuth grant at connect time.
if (gateway.supportsNativeHealth) {
final granted = await gateway.requestPermissions();
if (!granted) {
await _refresh(
statusMessage: 'Health permissions were denied.',
error: StateError('Health permissions denied'),
);
return false;
}
}
final now = DateTime.now();

View File

@ -289,7 +289,9 @@ class _WeightTrackerCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final biometric = ref.watch(biometricControllerProvider);
final supported = ref.watch(healthGatewayProvider).supportsNativeHealth;
final gateway = ref.watch(healthGatewayProvider);
final supported =
gateway.supportsNativeHealth || gateway.supportsCloudSync;
// Surface sync/manual outcomes as snackbars whenever the status changes.
ref.listen(biometricControllerProvider, (previous, next) {
@ -453,15 +455,28 @@ class _WeightTrackerCard extends ConsumerWidget {
);
}
/// Native platforms run the health sync; Web/Windows/macOS open the manual
/// entry dialog (the gateway reports `supportsNativeHealth == false`).
/// Syncs from the first available source — HealthKit / Health Connect on
/// mobile, a connected cloud partner (Google Health) on desktop/web — and
/// surfaces the sync outcome as a snackbar. Falls back to the manual entry
/// dialog when no source is available or connected (the dedicated
/// "Manual" button always opens it regardless).
Future<void> _logWeightOrSync(BuildContext context, WidgetRef ref) async {
final supported = ref.read(healthGatewayProvider).supportsNativeHealth;
if (!supported) {
final gateway = ref.read(healthGatewayProvider);
final canSync =
gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
if (!canSync) {
await showLogWeightDialog(context);
return;
}
await ref.read(biometricControllerProvider.notifier).syncFromHealth();
if (!context.mounted) return;
final message =
ref.read(biometricControllerProvider).valueOrNull?.statusMessage;
if (message != null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -15,6 +17,7 @@ class IntegrationsState {
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
this.fhirStatus = HealthConnectionStatus.notConfigured,
this.googleHealthStatus = HealthConnectionStatus.notConfigured,
this.isRequestingPermissions = false,
this.errorMessage,
});
@ -32,6 +35,9 @@ class IntegrationsState {
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
final HealthConnectionStatus fhirStatus;
/// Google Health cloud connector status (OAuth2 + REST; desktop/web)..
final HealthConnectionStatus googleHealthStatus;
/// `true` while a native permission round-trip is in flight.
final bool isRequestingPermissions;
@ -46,6 +52,10 @@ class IntegrationsState {
bool get healthConnectConnected =>
healthConnectStatus == HealthConnectionStatus.connected;
/// Whether the Google Health cloud connector holds a valid OAuth grant.
bool get googleHealthConnected =>
googleHealthStatus == HealthConnectionStatus.connected;
/// True when the device offers no native health SDK at all (Web/Windows/
/// macOS fallback driver) — the UI disables native connect buttons.
bool get nativeHealthUnsupported =>
@ -56,6 +66,7 @@ class IntegrationsState {
HealthConnectionStatus? appleHealthStatus,
HealthConnectionStatus? healthConnectStatus,
HealthConnectionStatus? fhirStatus,
HealthConnectionStatus? googleHealthStatus,
bool? isRequestingPermissions,
Object? errorMessage = _keep,
}) =>
@ -63,6 +74,7 @@ class IntegrationsState {
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
fhirStatus: fhirStatus ?? this.fhirStatus,
googleHealthStatus: googleHealthStatus ?? this.googleHealthStatus,
isRequestingPermissions:
isRequestingPermissions ?? this.isRequestingPermissions,
errorMessage: identical(errorMessage, _keep)
@ -115,10 +127,13 @@ class IntegrationsController extends Notifier<IntegrationsState> {
if (_disposed) return;
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
if (_disposed) return;
final googleHealth = await gateway.checkStatus(HealthProviderType.googleHealth);
if (_disposed) return;
state = state.copyWith(
appleHealthStatus: apple,
healthConnectStatus: connect,
fhirStatus: fhir,
googleHealthStatus: googleHealth,
);
}
@ -131,6 +146,17 @@ class IntegrationsController extends Notifier<IntegrationsState> {
Future<void> connectHealthConnect() =>
_connect(HealthProviderType.healthConnect);
/// Runs the Google Health cloud OAuth round trip (system browser + loopback
/// redirect); a granted round-trip immediately imports the account's
/// biometrics through the biometric controller.
Future<void> connectGoogleHealth() async {
await _connect(HealthProviderType.googleHealth);
if (_disposed || !state.googleHealthConnected) return;
unawaited(
ref.read(biometricControllerProvider.notifier).syncFromHealth(),
);
}
/// Tears down the binding for [provider] and re-probes its status.
///
/// HealthKit / Health Connect expose no programmatic revocation to
@ -207,6 +233,8 @@ class IntegrationsController extends Notifier<IntegrationsState> {
state = state.copyWith(healthConnectStatus: status);
case HealthProviderType.enterpriseFhir:
state = state.copyWith(fhirStatus: status);
case HealthProviderType.googleHealth:
state = state.copyWith(googleHealthStatus: status);
case HealthProviderType.manualFallback:
break;
}

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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,
);
});
});
}

View File

@ -122,13 +122,15 @@ void main() {
expect(find.text('Health Integrations'), findsOneWidget);
expect(find.text('Apple Health'), findsOneWidget);
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
expect(find.text('Google Health'), findsOneWidget);
expect(find.text('SMART on FHIR'), findsOneWidget);
// Native providers are unsupported on desktop; the FHIR connector is
// not provisioned yet — every Connect affordance is disabled.
expect(find.text('Unsupported'), findsNWidgets(2));
// Native providers and the (web-stubbed) Google Health connector are
// unsupported on desktop; the FHIR connector is not provisioned yet —
// every Connect affordance is disabled.
expect(find.text('Unsupported'), findsNWidgets(3));
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
expect(connectButtons, findsNWidgets(3));
expect(connectButtons, findsNWidgets(4));
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
expect(button.onPressed, isNull);
}