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:
parent
298cadef32
commit
5b5e7e2f57
|
|
@ -130,6 +130,26 @@
|
||||||
- [x] Single-instance enforcement (2026-10-17): Supabase Auth server logs (`POST /token` grant_type=pkce → 200 at 18:40:59Z, `user_signedup` for `mtkell@kellengineering.com`) proved the Microsoft exchange completed — in the *wrong window*. Two instances were running (protocol log: launches at 14:16 AND 14:37); the PKCE verifier lives in shared secure storage under one fixed key, so the callback was forwarded (Z-order) to the non-initiating instance, which silently completed the exchange server-side while the user's window stayed on the login screen. Fix: `AcquireSingleInstanceMutex()` (`Local\GLPCompanionSingleInstance`) in the Windows runner — duplicate normal launches activate the existing window and exit; protocol activations still forward to the sole window. Callback delivery is now deterministic.
|
- [x] 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] 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] 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)
|
## 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.
|
- [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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import '../health_gateway.dart';
|
||||||
|
|
||||||
|
/// Cloud health partner (Google Health, Withings, ...) reachable via OAuth2 + REST
|
||||||
|
/// from platforms that lack a native health SDK (architecture rule 3:
|
||||||
|
/// desktop/web third-party OAuth REST sync).
|
||||||
|
///
|
||||||
|
/// Implementations normalize everything into the gateway's display units
|
||||||
|
/// (pounds / kcal) so callers never deal with provider units. Declared in
|
||||||
|
/// `core` so features and the platform layer share one contract; the
|
||||||
|
/// dependency points inward: implementations live beside the gateway and are
|
||||||
|
/// injected through `createHealthGateway`.
|
||||||
|
abstract class ICloudHealthConnector {
|
||||||
|
/// Gateway provider slot this connector fills.
|
||||||
|
HealthProviderType get providerType;
|
||||||
|
|
||||||
|
/// `connected` when a (possibly refreshed) OAuth session is stored,
|
||||||
|
/// `notConfigured` otherwise, `unsupportedPlatform` on platforms the
|
||||||
|
/// connector cannot run (e.g. Web).
|
||||||
|
Future<HealthConnectionStatus> checkStatus();
|
||||||
|
|
||||||
|
/// Runs the OAuth2 authorization-code + PKCE round trip (opens the system
|
||||||
|
/// browser, waits for the loopback redirect) and persists tokens.
|
||||||
|
/// Returns whether the user completed the flow.
|
||||||
|
Future<bool> authorize();
|
||||||
|
|
||||||
|
/// Clears stored tokens (local revocation; the provider-side grant stays
|
||||||
|
/// until revoked in the partner's own settings).
|
||||||
|
Future<void> disconnect();
|
||||||
|
|
||||||
|
/// Weight samples in pounds for `[start, end]`, newest first.
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Active energy burned (kcal) samples for `[start, end]`, newest first.
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Platform-selected Google Health connector entry point (the successor to
|
||||||
|
// the retired Fitbit Web API — the Google Health API carries Fitbit, Pixel
|
||||||
|
// and Wear OS data).
|
||||||
|
//
|
||||||
|
// Mirrors the gateway's conditional-import discipline: Web receives the
|
||||||
|
// unsupported stub, every `dart:io` platform receives the real OAuth + REST
|
||||||
|
// connector. Import this file (not the implementations) to stay
|
||||||
|
// web-compilation-safe.
|
||||||
|
export 'google_health_connector_stub.dart'
|
||||||
|
if (dart.library.io) 'google_health_connector_io.dart';
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../health_gateway.dart';
|
||||||
|
import 'cloud_health_connector.dart';
|
||||||
|
import 'google_health_parser.dart';
|
||||||
|
|
||||||
|
/// Conditional-import entry point used by the gateway wiring: the real
|
||||||
|
/// connector only exists where `dart:io` does (Windows/macOS/Linux/Android/
|
||||||
|
/// iOS); Web falls back to the unsupported stub.
|
||||||
|
ICloudHealthConnector createGoogleHealthConnector() =>
|
||||||
|
GoogleHealthConnector();
|
||||||
|
|
||||||
|
/// OAuth2 token bundle persisted per Google Health account.
|
||||||
|
class GoogleHealthTokens {
|
||||||
|
const GoogleHealthTokens({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String accessToken;
|
||||||
|
final String refreshToken;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'access_token': accessToken,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
'expires_at': expiresAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
static GoogleHealthTokens? tryParse(Object? raw) {
|
||||||
|
if (raw is! Map<String, dynamic>) return null;
|
||||||
|
final access = raw['access_token'];
|
||||||
|
final refresh = raw['refresh_token'];
|
||||||
|
final expiresAt = raw['expires_at'];
|
||||||
|
if (access is! String || refresh is! String || expiresAt is! num) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return GoogleHealthTokens(
|
||||||
|
accessToken: access,
|
||||||
|
refreshToken: refresh,
|
||||||
|
expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt.toInt()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
|
||||||
|
/// the system browser with a loopback redirect
|
||||||
|
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
|
||||||
|
/// transparent refresh, and normalized weight / active-energy reads from the
|
||||||
|
/// Google Health API — the official successor to the retired Fitbit Web API
|
||||||
|
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
|
||||||
|
/// Health.
|
||||||
|
///
|
||||||
|
/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
|
||||||
|
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
|
||||||
|
/// them in a Google Cloud project with the Google Health API enabled; the
|
||||||
|
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
|
||||||
|
class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
|
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
||||||
|
: _client = client ?? http.Client(),
|
||||||
|
_storage = storage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
/// Storage key for the persisted [GoogleHealthTokens] JSON.
|
||||||
|
static const String tokenStorageKey = 'google_health.oauth.tokens';
|
||||||
|
|
||||||
|
static const String _authorizeEndpoint =
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
|
static const String _tokenEndpoint = 'https://oauth2.googleapis.com/token';
|
||||||
|
static const String _apiRoot = 'https://health.googleapis.com/v4';
|
||||||
|
|
||||||
|
/// Read-only scopes: steps/energy live under activity & fitness, weight
|
||||||
|
/// under health metrics (scopes page: request only what you need).
|
||||||
|
static const String _scopes =
|
||||||
|
'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly'
|
||||||
|
' https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly';
|
||||||
|
|
||||||
|
/// Data type identifiers from the Google Health data-types index.
|
||||||
|
static const String _weightDataType = 'weight';
|
||||||
|
static const String _activeEnergyDataType = 'active-energy-burned';
|
||||||
|
|
||||||
|
static const int _pageSize = 1000;
|
||||||
|
static const Duration _expiryMargin = Duration(minutes: 2);
|
||||||
|
static const Duration _callbackTimeout = Duration(minutes: 5);
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
String? get _clientId => dotenv.env['GOOGLE_HEALTH_CLIENT_ID'];
|
||||||
|
String? get _clientSecret => dotenv.env['GOOGLE_HEALTH_CLIENT_SECRET'];
|
||||||
|
|
||||||
|
int get _redirectPort => int.tryParse(
|
||||||
|
dotenv.env['GOOGLE_HEALTH_REDIRECT_PORT'] ?? '',
|
||||||
|
) ??
|
||||||
|
52789;
|
||||||
|
|
||||||
|
Uri get _redirectUri => Uri.parse('http://localhost:$_redirectPort/callback');
|
||||||
|
|
||||||
|
bool get _isConfigured =>
|
||||||
|
(_clientId?.isNotEmpty ?? false) && (_clientSecret?.isNotEmpty ?? false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
HealthProviderType get providerType => HealthProviderType.googleHealth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthConnectionStatus> checkStatus() async {
|
||||||
|
if (!_isConfigured) return HealthConnectionStatus.notConfigured;
|
||||||
|
final tokens = await _readTokens();
|
||||||
|
return tokens == null
|
||||||
|
? HealthConnectionStatus.notConfigured
|
||||||
|
: HealthConnectionStatus.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> authorize() async {
|
||||||
|
if (!_isConfigured) {
|
||||||
|
debugPrint(
|
||||||
|
'GoogleHealthConnector: GOOGLE_HEALTH_CLIENT_ID / '
|
||||||
|
'GOOGLE_HEALTH_CLIENT_SECRET missing from .env - cloud sync stays '
|
||||||
|
'unconfigured.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final verifier = _randomUrlSafe(64);
|
||||||
|
final authorizeUrl = Uri.parse(
|
||||||
|
'$_authorizeEndpoint?response_type=code'
|
||||||
|
'&client_id=${Uri.encodeComponent(_clientId!)}'
|
||||||
|
'&redirect_uri=${Uri.encodeComponent(_redirectUri.toString())}'
|
||||||
|
'&scope=${Uri.encodeComponent(_scopes)}'
|
||||||
|
'&code_challenge=${_codeChallengeS256(verifier)}'
|
||||||
|
'&code_challenge_method=S256'
|
||||||
|
// Offline access + explicit consent keep the refresh token stable
|
||||||
|
// (Google only returns a refresh token on the first consent).
|
||||||
|
'&access_type=offline'
|
||||||
|
'&prompt=consent'
|
||||||
|
'&include_granted_scopes=true',
|
||||||
|
);
|
||||||
|
|
||||||
|
final server = await _bindLoopback();
|
||||||
|
if (server == null) return false;
|
||||||
|
try {
|
||||||
|
if (!await launchUrl(authorizeUrl, mode: LaunchMode.platformDefault)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final code = await _awaitCallbackCode(server);
|
||||||
|
final tokens = await _requestTokens({
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code,
|
||||||
|
'code_verifier': verifier,
|
||||||
|
'redirect_uri': _redirectUri.toString(),
|
||||||
|
});
|
||||||
|
await _writeTokens(tokens);
|
||||||
|
return true;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'GoogleHealthConnector: authorization failed ($error)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await server.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _storage.delete(key: tokenStorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _fetchMetrics(
|
||||||
|
dataType: _weightDataType,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
parse: (payload) => googleHealthWeightMetrics(
|
||||||
|
payload,
|
||||||
|
since: start,
|
||||||
|
until: end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'GoogleHealthConnector: weight fetch failed (non-fatal)',
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _fetchMetrics(
|
||||||
|
dataType: _activeEnergyDataType,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
parse: (payload) => googleHealthActiveEnergyMetrics(
|
||||||
|
payload,
|
||||||
|
since: start,
|
||||||
|
until: end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'GoogleHealthConnector: energy fetch failed (non-fatal)',
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks every page of `users/me/dataTypes/{dataType}/dataPoints` (the API
|
||||||
|
/// caps pages at `pageSize`; `nextPageToken` chains until exhausted) and
|
||||||
|
/// merges the parsed samples, newest first. Server-side filters are
|
||||||
|
/// deliberately avoided — the connector filters client-side against
|
||||||
|
/// `[start, end]` so the parsing rules stay unit-tested and predictable.
|
||||||
|
Future<List<HealthMetric>> _fetchMetrics({
|
||||||
|
required String dataType,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
required List<HealthMetric> Function(Object? payload) parse,
|
||||||
|
}) async {
|
||||||
|
final tokens = await _validTokens();
|
||||||
|
final samples = <HealthMetric>[];
|
||||||
|
String? pageToken;
|
||||||
|
do {
|
||||||
|
final query = StringBuffer('pageSize=$_pageSize');
|
||||||
|
if (pageToken != null) {
|
||||||
|
query.write('&pageToken=${Uri.encodeComponent(pageToken)}');
|
||||||
|
}
|
||||||
|
final payload = await _getJson(
|
||||||
|
tokens,
|
||||||
|
'/users/me/dataTypes/$dataType/dataPoints?$query',
|
||||||
|
);
|
||||||
|
samples.addAll(parse(payload));
|
||||||
|
pageToken =
|
||||||
|
payload is Map<String, dynamic> ? payload['nextPageToken'] as String? : null;
|
||||||
|
} while (pageToken != null && pageToken.isNotEmpty);
|
||||||
|
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loopback redirect listener (RFC 8252 native-app pattern; Google accepts
|
||||||
|
// localhost redirect URIs registered on the OAuth client).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<HttpServer?> _bindLoopback() async {
|
||||||
|
try {
|
||||||
|
return await HttpServer.bind(InternetAddress.loopbackIPv4, _redirectPort);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'GoogleHealthConnector: could not bind the loopback redirect '
|
||||||
|
'port $_redirectPort (is another app using it?)',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _awaitCallbackCode(HttpServer server) {
|
||||||
|
final completer = Completer<String>();
|
||||||
|
server.listen((request) async {
|
||||||
|
final params = request.uri.queryParameters;
|
||||||
|
final error = params['error'];
|
||||||
|
final code = params['code'];
|
||||||
|
request.response
|
||||||
|
..statusCode = HttpStatus.ok
|
||||||
|
..headers.contentType = ContentType.html
|
||||||
|
..write(error != null
|
||||||
|
? '<html><body><h3>Google Health sign-in failed</h3>'
|
||||||
|
'<p>${params['error_description'] ?? error}</p>'
|
||||||
|
'<p>You can close this window.</p></body></html>'
|
||||||
|
: '<html><body><h3>GLP-1 Companion</h3>'
|
||||||
|
'<p>Google Health connected. Close this window and return '
|
||||||
|
'to the app.</p></body></html>');
|
||||||
|
await request.response.close();
|
||||||
|
if (completer.isCompleted) return;
|
||||||
|
if (error != null || code == null) {
|
||||||
|
completer.completeError(
|
||||||
|
FormatException(
|
||||||
|
'Google Health authorization failed: '
|
||||||
|
'${params['error_description'] ?? error ?? 'no code returned'}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
completer.complete(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future.timeout(
|
||||||
|
_callbackTimeout,
|
||||||
|
onTimeout: () => throw TimeoutException(
|
||||||
|
'Timed out waiting for the Google Health redirect.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token lifecycle.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<GoogleHealthTokens?> _readTokens() async {
|
||||||
|
try {
|
||||||
|
final raw = await _storage.read(key: tokenStorageKey);
|
||||||
|
if (raw == null) return null;
|
||||||
|
return GoogleHealthTokens.tryParse(jsonDecode(raw));
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('GoogleHealthConnector: token read failed ($error)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeTokens(GoogleHealthTokens tokens) =>
|
||||||
|
_storage.write(key: tokenStorageKey, value: jsonEncode(tokens.toJson()));
|
||||||
|
|
||||||
|
/// Returns a valid access token, transparently refreshing an expired one.
|
||||||
|
Future<GoogleHealthTokens> _validTokens() async {
|
||||||
|
final tokens = await _readTokens();
|
||||||
|
if (tokens == null) {
|
||||||
|
throw StateError('Google Health is not connected.');
|
||||||
|
}
|
||||||
|
if (DateTime.now().isBefore(tokens.expiresAt.subtract(_expiryMargin))) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
final refreshed = await _requestTokens({
|
||||||
|
'grant_type': 'refresh_token',
|
||||||
|
'refresh_token': tokens.refreshToken,
|
||||||
|
});
|
||||||
|
await _writeTokens(refreshed);
|
||||||
|
return refreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GoogleHealthTokens> _requestTokens(Map<String, String> form) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
Uri.parse(_tokenEndpoint),
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: {
|
||||||
|
...form,
|
||||||
|
'client_id': _clientId!,
|
||||||
|
'client_secret': _clientSecret!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
|
throw http.ClientException(
|
||||||
|
'Google Health token request failed (${response.statusCode}): '
|
||||||
|
'${response.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final access = json['access_token'];
|
||||||
|
final refresh = json['refresh_token'];
|
||||||
|
if (access is! String) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Google Health token response had no access_token.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final expiresIn =
|
||||||
|
Duration(seconds: (json['expires_in'] as num?)?.toInt() ?? 3600);
|
||||||
|
return GoogleHealthTokens(
|
||||||
|
accessToken: access,
|
||||||
|
// Google omits refresh_token on some re-consent flows; keep the
|
||||||
|
// previously stored one in that case.
|
||||||
|
refreshToken: refresh is String && refresh.isNotEmpty
|
||||||
|
? refresh
|
||||||
|
: (await _readTokens())?.refreshToken ?? '',
|
||||||
|
expiresAt: DateTime.now().add(expiresIn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// REST helpers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<Object?> _getJson(GoogleHealthTokens tokens, String path) async {
|
||||||
|
Uri url() => Uri.parse('$_apiRoot$path');
|
||||||
|
var response = await _client
|
||||||
|
.get(url(), headers: {'Authorization': 'Bearer ${tokens.accessToken}'});
|
||||||
|
if (response.statusCode == HttpStatus.unauthorized) {
|
||||||
|
// Server-side invalidation: refresh once and retry.
|
||||||
|
final refreshed = await _requestTokens({
|
||||||
|
'grant_type': 'refresh_token',
|
||||||
|
'refresh_token': tokens.refreshToken,
|
||||||
|
});
|
||||||
|
await _writeTokens(refreshed);
|
||||||
|
response = await _client.get(url(),
|
||||||
|
headers: {'Authorization': 'Bearer ${refreshed.accessToken}'});
|
||||||
|
}
|
||||||
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
|
throw http.ClientException(
|
||||||
|
'Google Health request failed (${response.statusCode}) for $path',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PKCE helpers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static String _randomUrlSafe(int byteCount) {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(byteCount, (_) => random.nextInt(256));
|
||||||
|
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _codeChallengeS256(String verifier) =>
|
||||||
|
base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes)
|
||||||
|
.replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import '../health_gateway.dart';
|
||||||
|
import 'cloud_health_connector.dart';
|
||||||
|
|
||||||
|
/// Web fallback for the Google Health connector: cloud OAuth needs a
|
||||||
|
/// loopback redirect and secure token storage, neither of which exist on
|
||||||
|
/// Web, so the connector reports `unsupportedPlatform` and performs nothing
|
||||||
|
/// (the same zero-platform-channel discipline as the rest of the fallback
|
||||||
|
/// driver).
|
||||||
|
ICloudHealthConnector createGoogleHealthConnector() =>
|
||||||
|
const _WebGoogleHealthConnector();
|
||||||
|
|
||||||
|
class _WebGoogleHealthConnector implements ICloudHealthConnector {
|
||||||
|
const _WebGoogleHealthConnector();
|
||||||
|
|
||||||
|
@override
|
||||||
|
HealthProviderType get providerType => HealthProviderType.googleHealth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthConnectionStatus> checkStatus() async =>
|
||||||
|
HealthConnectionStatus.unsupportedPlatform;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> authorize() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import '../health_gateway.dart';
|
||||||
|
|
||||||
|
/// Kilograms to pounds (exact conversion factor used across the app).
|
||||||
|
const double kgToLbs = 2.20462262185;
|
||||||
|
|
||||||
|
/// Parses a Google Health API `dataPoints` list response for **weight**
|
||||||
|
/// (`GET /v4/users/me/dataTypes/weight/dataPoints`) into normalized
|
||||||
|
/// [HealthMetric] samples.
|
||||||
|
///
|
||||||
|
/// Each data point carries its value under the `weight` key
|
||||||
|
/// (`{"unit": "kg" | "lb", "value": <number>}`) and its time either as
|
||||||
|
/// `instantaneousTime` or as `timeInterval.startTime` (RFC 3339). Google
|
||||||
|
/// stores weight in kilograms by default; the `unit` field drives
|
||||||
|
/// normalization into pounds. Samples outside `[since, until]` are dropped
|
||||||
|
/// (the connector does not rely on server-side filters) and the result is
|
||||||
|
/// newest-first.
|
||||||
|
List<HealthMetric> googleHealthWeightMetrics(
|
||||||
|
Object? payload, {
|
||||||
|
required DateTime since,
|
||||||
|
required DateTime until,
|
||||||
|
}) =>
|
||||||
|
_metricsFromDataPoints(
|
||||||
|
payload,
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
parse: (point) {
|
||||||
|
final weight = point['weight'];
|
||||||
|
if (weight is! Map<String, dynamic>) return null;
|
||||||
|
final value = weight['value'];
|
||||||
|
if (value is! num) return null;
|
||||||
|
final isPounds = weight['unit'] == 'lb';
|
||||||
|
return HealthMetric(
|
||||||
|
timestamp: DateTime.now(), // replaced by the caller
|
||||||
|
value: isPounds ? value.toDouble() : value.toDouble() * kgToLbs,
|
||||||
|
unit: 'lb',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Parses a Google Health API `dataPoints` list response for **active
|
||||||
|
/// energy** (`GET /v4/users/me/dataTypes/active-energy-burned/dataPoints`)
|
||||||
|
/// into normalized kcal [HealthMetric] samples (values pass through — the
|
||||||
|
/// API reports energy in kcal).
|
||||||
|
List<HealthMetric> googleHealthActiveEnergyMetrics(
|
||||||
|
Object? payload, {
|
||||||
|
required DateTime since,
|
||||||
|
required DateTime until,
|
||||||
|
}) =>
|
||||||
|
_metricsFromDataPoints(
|
||||||
|
payload,
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
parse: (point) {
|
||||||
|
final energy = point['activeEnergyBurned'];
|
||||||
|
if (energy is! Map<String, dynamic>) return null;
|
||||||
|
final value = energy['value'];
|
||||||
|
if (value is! num) return null;
|
||||||
|
return HealthMetric(
|
||||||
|
timestamp: DateTime.now(), // replaced by the caller
|
||||||
|
value: value.toDouble(),
|
||||||
|
unit: 'kcal',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Shared dataPoints-list walk: extracts the timestamp (preferring
|
||||||
|
/// `instantaneousTime`, falling back to `timeInterval.startTime`), applies
|
||||||
|
/// [parse] to the typed payload, and assembles newest-first samples inside
|
||||||
|
/// `[since, until]`.
|
||||||
|
List<HealthMetric> _metricsFromDataPoints(
|
||||||
|
Object? payload, {
|
||||||
|
required DateTime since,
|
||||||
|
required DateTime until,
|
||||||
|
required HealthMetric? Function(Map<String, dynamic> point) parse,
|
||||||
|
}) {
|
||||||
|
if (payload is! Map<String, dynamic>) return const [];
|
||||||
|
final dataPoints = payload['dataPoints'];
|
||||||
|
if (dataPoints is! List) return const [];
|
||||||
|
|
||||||
|
final samples = <HealthMetric>[];
|
||||||
|
for (final raw in dataPoints) {
|
||||||
|
if (raw is! Map<String, dynamic>) continue;
|
||||||
|
final timestamp = _timestampOf(raw);
|
||||||
|
if (timestamp == null) continue;
|
||||||
|
if (timestamp.isBefore(since) || timestamp.isAfter(until)) continue;
|
||||||
|
final parsed = parse(raw);
|
||||||
|
if (parsed == null) continue;
|
||||||
|
samples.add(
|
||||||
|
HealthMetric(timestamp: timestamp, value: parsed.value, unit: parsed.unit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
samples.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the sample time from a data point: `instantaneousTime` first
|
||||||
|
/// (Sample records), then `timeInterval.startTime` (Interval/Daily).
|
||||||
|
DateTime? _timestampOf(Map<String, dynamic> point) {
|
||||||
|
final instant = point['instantaneousTime'];
|
||||||
|
if (instant is String) {
|
||||||
|
final parsed = DateTime.tryParse(instant);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
final interval = point['timeInterval'];
|
||||||
|
if (interval is Map<String, dynamic>) {
|
||||||
|
final start = interval['startTime'];
|
||||||
|
if (start is String) return DateTime.tryParse(start);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'cloud_health/cloud_health_connector.dart';
|
||||||
import 'health_gateway_fallback.dart'
|
import 'health_gateway_fallback.dart'
|
||||||
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
|
if (dart.library.io) 'health_gateway_mobile.dart' as impl;
|
||||||
|
|
||||||
|
|
@ -26,6 +27,12 @@ enum HealthProviderType {
|
||||||
samsungHealth,
|
samsungHealth,
|
||||||
enterpriseFhir,
|
enterpriseFhir,
|
||||||
manualFallback,
|
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
|
/// Entitlement / read-permission state reported by
|
||||||
|
|
@ -133,6 +140,17 @@ abstract class IHealthGateway {
|
||||||
/// Web/Windows/macOS, which signals the UI to offer manual entry.
|
/// Web/Windows/macOS, which signals the UI to offer manual entry.
|
||||||
bool get supportsNativeHealth;
|
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.
|
/// Ecosystem backing [supportsNativeHealth], or `null` in fallback mode.
|
||||||
NativeHealthPlatform? get nativePlatform;
|
NativeHealthPlatform? get nativePlatform;
|
||||||
|
|
||||||
|
|
@ -256,5 +274,11 @@ abstract class IHealthGateway {
|
||||||
///
|
///
|
||||||
/// [localReader] backs fallback reads with the locally logged biometric
|
/// [localReader] backs fallback reads with the locally logged biometric
|
||||||
/// cache; mobile platforms ignore it and query the native store directly.
|
/// cache; mobile platforms ignore it and query the native store directly.
|
||||||
IHealthGateway createHealthGateway({ILocalBiometricReader? localReader}) =>
|
IHealthGateway createHealthGateway({
|
||||||
impl.makeHealthGateway(localReader: localReader);
|
ILocalBiometricReader? localReader,
|
||||||
|
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||||
|
}) =>
|
||||||
|
impl.makeHealthGateway(
|
||||||
|
localReader: localReader,
|
||||||
|
cloudConnectors: cloudConnectors,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'cloud_health/cloud_health_connector.dart';
|
||||||
import 'health_gateway.dart';
|
import 'health_gateway.dart';
|
||||||
|
|
||||||
/// Conditional-import entry point used by `health_gateway.dart`.
|
/// Conditional-import entry point used by `health_gateway.dart`.
|
||||||
///
|
///
|
||||||
/// [localReader] (usually the drift-backed biometric cache) lets fallback
|
/// [localReader] (usually the drift-backed biometric cache) lets fallback
|
||||||
/// reads serve locally logged data instead of nothing.
|
/// reads serve locally logged data instead of nothing. [cloudConnectors]
|
||||||
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
/// add third-party OAuth REST sync (Google Health, ...) on desktop/web.
|
||||||
FallbackHealthGateway(localBiometricReader: localReader);
|
IHealthGateway makeHealthGateway({
|
||||||
|
ILocalBiometricReader? localReader,
|
||||||
|
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||||
|
}) =>
|
||||||
|
FallbackHealthGateway(
|
||||||
|
localBiometricReader: localReader,
|
||||||
|
cloudConnectors: cloudConnectors,
|
||||||
|
);
|
||||||
|
|
||||||
/// Graceful-degradation gateway for Web, Windows, macOS, and Linux
|
/// Graceful-degradation gateway for Web, Windows, macOS, and Linux
|
||||||
/// (architecture rule 3: desktop/web get read-only manual entry until cloud
|
/// (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
|
/// wired. Consumers treat `supportsNativeHealth == false` as the signal to
|
||||||
/// open the manual-entry dialog.
|
/// open the manual-entry dialog.
|
||||||
class FallbackHealthGateway extends IHealthGateway {
|
class FallbackHealthGateway extends IHealthGateway {
|
||||||
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
|
FallbackHealthGateway({
|
||||||
: _localReader = localBiometricReader {
|
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
|
// Debug-only diagnostic: makes the active driver obvious while
|
||||||
// troubleshooting platform issues; `debugPrint` is stripped in release.
|
// troubleshooting platform issues; `debugPrint` is stripped in release.
|
||||||
debugPrint(
|
debugPrint(
|
||||||
|
|
@ -38,28 +52,85 @@ class FallbackHealthGateway extends IHealthGateway {
|
||||||
|
|
||||||
final ILocalBiometricReader? _localReader;
|
final ILocalBiometricReader? _localReader;
|
||||||
|
|
||||||
|
/// Cloud partners (Google Health, ...) keyed by the provider slot they fill.
|
||||||
|
final Map<HealthProviderType, ICloudHealthConnector> _cloudConnectors;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get supportsNativeHealth => false;
|
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
|
@override
|
||||||
NativeHealthPlatform? get nativePlatform => null;
|
NativeHealthPlatform? get nativePlatform => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requestPermissions() async => false;
|
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
|
@override
|
||||||
Future<List<HealthMetric>> fetchWeightHistory({
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
}) =>
|
}) async {
|
||||||
_read(() => _localReader?.readWeightRange(start: start, end: end));
|
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
|
@override
|
||||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
}) =>
|
}) async {
|
||||||
_read(() => _localReader?.readEnergyRange(start: start, end: end));
|
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
|
@override
|
||||||
Future<bool> writeWeight({
|
Future<bool> writeWeight({
|
||||||
|
|
@ -88,4 +159,21 @@ class FallbackHealthGateway extends IHealthGateway {
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cloud connector read that degrades to an empty result (never throws)
|
||||||
|
/// when the partner API errors, so a provider outage can never break the
|
||||||
|
/// local/manual data path.
|
||||||
|
Future<List<HealthMetric>> _cloudRead(
|
||||||
|
Future<List<HealthMetric>> Function() read,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await read();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrintStack(
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
label: 'HealthGateway: cloud connector read failed (non-fatal)',
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,15 +3,23 @@ import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:health/health.dart';
|
import 'package:health/health.dart';
|
||||||
|
|
||||||
|
import 'cloud_health/cloud_health_connector.dart';
|
||||||
import 'health_gateway.dart';
|
import 'health_gateway.dart';
|
||||||
|
|
||||||
/// Conditional-import entry point used by `health_gateway.dart`.
|
/// Conditional-import entry point used by `health_gateway.dart`.
|
||||||
///
|
///
|
||||||
/// [localReader] is accepted for factory-signature parity with the fallback
|
/// This io-platform driver runs on Android, iOS, **and desktop** (the
|
||||||
/// driver; native platforms never need it because reads are served by
|
/// `dart.library.io` conditional import routes every non-Web platform here;
|
||||||
/// Health Connect / HealthKit directly.
|
/// the fallback driver is Web-only). [cloudConnectors] are therefore stored,
|
||||||
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
/// not discarded: desktop builds rely on them as their only sync source,
|
||||||
MobileHealthGateway();
|
/// 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
|
/// `package:health`-backed gateway for Android (Health Connect) and iOS
|
||||||
/// (HealthKit).
|
/// (HealthKit).
|
||||||
|
|
@ -35,9 +43,34 @@ IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
|
||||||
/// HealthKit entitlements or hardware quirks can never surface as
|
/// HealthKit entitlements or hardware quirks can never surface as
|
||||||
/// unhandled exceptions.
|
/// unhandled exceptions.
|
||||||
class MobileHealthGateway extends IHealthGateway {
|
class MobileHealthGateway extends IHealthGateway {
|
||||||
|
MobileHealthGateway({
|
||||||
|
List<ICloudHealthConnector> cloudConnectors = const [],
|
||||||
|
}) : _cloudConnectors = {
|
||||||
|
for (final connector in cloudConnectors)
|
||||||
|
connector.providerType: connector,
|
||||||
|
};
|
||||||
|
|
||||||
final Health _health = Health();
|
final Health _health = Health();
|
||||||
Future<void>? _configuration;
|
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.
|
/// Health Connect SDK availability, probed once per gateway instance.
|
||||||
Future<bool>? _healthConnectReady;
|
Future<bool>? _healthConnectReady;
|
||||||
|
|
||||||
|
|
@ -83,6 +116,25 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
: NativeHealthPlatform.healthConnect;
|
: 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
|
/// `Health.configure()` resolves device info and must run exactly once
|
||||||
/// before any read/write call.
|
/// before any read/write call.
|
||||||
Future<void> _configure() => _configuration ??= _health.configure();
|
Future<void> _configure() => _configuration ??= _health.configure();
|
||||||
|
|
@ -167,6 +219,10 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
Future<HealthConnectionStatus> checkStatus(
|
Future<HealthConnectionStatus> checkStatus(
|
||||||
HealthProviderType provider,
|
HealthProviderType provider,
|
||||||
) async {
|
) 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) {
|
if (provider == HealthProviderType.manualFallback) {
|
||||||
return HealthConnectionStatus.connected;
|
return HealthConnectionStatus.connected;
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +255,8 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
Future<bool> requestAuthorization(HealthProviderType provider) async {
|
||||||
|
final cloudConnector = _cloudConnectors[provider];
|
||||||
|
if (cloudConnector != null) return cloudConnector.authorize();
|
||||||
if (provider == HealthProviderType.manualFallback) return true;
|
if (provider == HealthProviderType.manualFallback) return true;
|
||||||
if (!_providerMatches(provider)) return false;
|
if (!_providerMatches(provider)) return false;
|
||||||
return requestPermissions();
|
return requestPermissions();
|
||||||
|
|
@ -206,18 +264,21 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<HealthDataPayload?> fetchLatestBiometrics() async {
|
Future<HealthDataPayload?> fetchLatestBiometrics() async {
|
||||||
if (!supportsNativeHealth) return null;
|
if (!supportsNativeHealth && !supportsCloudSync) return null;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final start = DateTime(now.year, now.month, now.day);
|
final start = DateTime(now.year, now.month, now.day);
|
||||||
final weights = await fetchWeightHistory(start: start, end: now);
|
final weights = await fetchWeightHistory(start: start, end: now);
|
||||||
final energies = await fetchActiveEnergyBurned(start: start, end: now);
|
final energies = await fetchActiveEnergyBurned(start: start, end: now);
|
||||||
final glucose = await _fetch(
|
// Blood glucose is native-only; the cloud connectors don't expose it.
|
||||||
type: HealthDataType.BLOOD_GLUCOSE,
|
final glucose = supportsNativeHealth
|
||||||
start: start,
|
? await _fetch(
|
||||||
end: now,
|
type: HealthDataType.BLOOD_GLUCOSE,
|
||||||
normalize: _glucoseInMgDl,
|
start: start,
|
||||||
unit: 'mg/dL',
|
end: now,
|
||||||
);
|
normalize: _glucoseInMgDl,
|
||||||
|
unit: 'mg/dL',
|
||||||
|
)
|
||||||
|
: const <HealthMetric>[];
|
||||||
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
|
if (weights.isEmpty && energies.isEmpty && glucose.isEmpty) return null;
|
||||||
var energyTotal = 0.0;
|
var energyTotal = 0.0;
|
||||||
for (final sample in energies) {
|
for (final sample in energies) {
|
||||||
|
|
@ -234,6 +295,11 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disconnect(HealthProviderType provider) async {
|
Future<void> disconnect(HealthProviderType provider) async {
|
||||||
|
final cloudConnector = _cloudConnectors[provider];
|
||||||
|
if (cloudConnector != null) {
|
||||||
|
await cloudConnector.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!_providerMatches(provider)) return;
|
if (!_providerMatches(provider)) return;
|
||||||
// Neither HealthKit nor Health Connect exposes programmatic permission
|
// Neither HealthKit nor Health Connect exposes programmatic permission
|
||||||
// revocation to third-party apps; clearing the cached SDK probe forces
|
// revocation to third-party apps; clearing the cached SDK probe forces
|
||||||
|
|
@ -249,27 +315,43 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
Future<List<HealthMetric>> fetchWeightHistory({
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
}) =>
|
}) async {
|
||||||
_fetch(
|
final merged = [
|
||||||
|
...await _fetch(
|
||||||
type: HealthDataType.WEIGHT,
|
type: HealthDataType.WEIGHT,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
normalize: _weightInLbs,
|
normalize: _weightInLbs,
|
||||||
unit: 'lb',
|
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
|
@override
|
||||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
}) =>
|
}) async {
|
||||||
_fetch(
|
final merged = [
|
||||||
|
...await _fetch(
|
||||||
type: HealthDataType.ACTIVE_ENERGY_BURNED,
|
type: HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
normalize: _energyInKcal,
|
normalize: _energyInKcal,
|
||||||
unit: 'kcal',
|
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
|
@override
|
||||||
Future<bool> writeWeight({
|
Future<bool> writeWeight({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/database/app_database.dart';
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/platform/cloud_health/google_health_connector.dart';
|
||||||
import '../../../../core/platform/health_gateway.dart';
|
import '../../../../core/platform/health_gateway.dart';
|
||||||
import '../../../../core/utils/date_time_utils.dart';
|
import '../../../../core/utils/date_time_utils.dart';
|
||||||
import '../../data/repositories/health_sync_repository.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,
|
/// 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
|
/// The fallback driver receives the encrypted drift cache as its
|
||||||
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
|
/// [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.
|
/// Overridable in tests with a fake gateway.
|
||||||
final healthGatewayProvider = Provider<IHealthGateway>(
|
final healthGatewayProvider = Provider<IHealthGateway>(
|
||||||
|
|
@ -28,6 +31,7 @@ final healthGatewayProvider = Provider<IHealthGateway>(
|
||||||
localReader: DriftLocalBiometricReader(
|
localReader: DriftLocalBiometricReader(
|
||||||
ref.watch(healthSyncRepositoryProvider),
|
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
|
/// Imports weight and active-energy samples from the native health store
|
||||||
/// over the trailing [window] and persists them idempotently (the previous
|
/// (or a connected cloud partner such as Fitbit) over the trailing
|
||||||
/// import for the same source/window is replaced, not duplicated).
|
/// [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 —
|
/// Returns `false` when no sync source exists on this platform (Web with
|
||||||
/// callers should open the manual-entry dialog instead), when permission
|
/// no cloud connector provisioned), when permission is denied, or when
|
||||||
/// is denied, or when the sync throws; the reason is reflected in
|
/// the sync throws; the reason is reflected in
|
||||||
/// [BiometricState.statusMessage].
|
/// [BiometricState.statusMessage].
|
||||||
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
|
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
|
||||||
final gateway = ref.read(healthGatewayProvider);
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
if (!gateway.supportsNativeHealth) {
|
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
|
||||||
await _refresh(
|
await _refresh(
|
||||||
statusMessage:
|
statusMessage:
|
||||||
'Native health sync is unavailable here - use manual entry.',
|
'Native health sync is unavailable here - use manual entry.',
|
||||||
|
|
@ -239,13 +244,17 @@ class BiometricController extends AsyncNotifier<BiometricState> {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final granted = await gateway.requestPermissions();
|
// Native stores need an OS permission grant first; cloud connectors
|
||||||
if (!granted) {
|
// manage their own OAuth grant at connect time.
|
||||||
await _refresh(
|
if (gateway.supportsNativeHealth) {
|
||||||
statusMessage: 'Health permissions were denied.',
|
final granted = await gateway.requestPermissions();
|
||||||
error: StateError('Health permissions denied'),
|
if (!granted) {
|
||||||
);
|
await _refresh(
|
||||||
return false;
|
statusMessage: 'Health permissions were denied.',
|
||||||
|
error: StateError('Health permissions denied'),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,9 @@ class _WeightTrackerCard extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final biometric = ref.watch(biometricControllerProvider);
|
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.
|
// Surface sync/manual outcomes as snackbars whenever the status changes.
|
||||||
ref.listen(biometricControllerProvider, (previous, next) {
|
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
|
/// Syncs from the first available source — HealthKit / Health Connect on
|
||||||
/// entry dialog (the gateway reports `supportsNativeHealth == false`).
|
/// 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 {
|
Future<void> _logWeightOrSync(BuildContext context, WidgetRef ref) async {
|
||||||
final supported = ref.read(healthGatewayProvider).supportsNativeHealth;
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
if (!supported) {
|
final canSync =
|
||||||
|
gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
|
||||||
|
if (!canSync) {
|
||||||
await showLogWeightDialog(context);
|
await showLogWeightDialog(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ref.read(biometricControllerProvider.notifier).syncFromHealth();
|
await ref.read(biometricControllerProvider.notifier).syncFromHealth();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final message =
|
||||||
|
ref.read(biometricControllerProvider).valueOrNull?.statusMessage;
|
||||||
|
if (message != null) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
@ -15,6 +17,7 @@ class IntegrationsState {
|
||||||
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
|
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
|
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.fhirStatus = HealthConnectionStatus.notConfigured,
|
this.fhirStatus = HealthConnectionStatus.notConfigured,
|
||||||
|
this.googleHealthStatus = HealthConnectionStatus.notConfigured,
|
||||||
this.isRequestingPermissions = false,
|
this.isRequestingPermissions = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
@ -32,6 +35,9 @@ class IntegrationsState {
|
||||||
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
|
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
|
||||||
final HealthConnectionStatus fhirStatus;
|
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.
|
/// `true` while a native permission round-trip is in flight.
|
||||||
final bool isRequestingPermissions;
|
final bool isRequestingPermissions;
|
||||||
|
|
||||||
|
|
@ -46,6 +52,10 @@ class IntegrationsState {
|
||||||
bool get healthConnectConnected =>
|
bool get healthConnectConnected =>
|
||||||
healthConnectStatus == HealthConnectionStatus.connected;
|
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/
|
/// True when the device offers no native health SDK at all (Web/Windows/
|
||||||
/// macOS fallback driver) — the UI disables native connect buttons.
|
/// macOS fallback driver) — the UI disables native connect buttons.
|
||||||
bool get nativeHealthUnsupported =>
|
bool get nativeHealthUnsupported =>
|
||||||
|
|
@ -56,6 +66,7 @@ class IntegrationsState {
|
||||||
HealthConnectionStatus? appleHealthStatus,
|
HealthConnectionStatus? appleHealthStatus,
|
||||||
HealthConnectionStatus? healthConnectStatus,
|
HealthConnectionStatus? healthConnectStatus,
|
||||||
HealthConnectionStatus? fhirStatus,
|
HealthConnectionStatus? fhirStatus,
|
||||||
|
HealthConnectionStatus? googleHealthStatus,
|
||||||
bool? isRequestingPermissions,
|
bool? isRequestingPermissions,
|
||||||
Object? errorMessage = _keep,
|
Object? errorMessage = _keep,
|
||||||
}) =>
|
}) =>
|
||||||
|
|
@ -63,6 +74,7 @@ class IntegrationsState {
|
||||||
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
|
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
|
||||||
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
|
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
|
||||||
fhirStatus: fhirStatus ?? this.fhirStatus,
|
fhirStatus: fhirStatus ?? this.fhirStatus,
|
||||||
|
googleHealthStatus: googleHealthStatus ?? this.googleHealthStatus,
|
||||||
isRequestingPermissions:
|
isRequestingPermissions:
|
||||||
isRequestingPermissions ?? this.isRequestingPermissions,
|
isRequestingPermissions ?? this.isRequestingPermissions,
|
||||||
errorMessage: identical(errorMessage, _keep)
|
errorMessage: identical(errorMessage, _keep)
|
||||||
|
|
@ -115,10 +127,13 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
|
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
final googleHealth = await gateway.checkStatus(HealthProviderType.googleHealth);
|
||||||
|
if (_disposed) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
appleHealthStatus: apple,
|
appleHealthStatus: apple,
|
||||||
healthConnectStatus: connect,
|
healthConnectStatus: connect,
|
||||||
fhirStatus: fhir,
|
fhirStatus: fhir,
|
||||||
|
googleHealthStatus: googleHealth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +146,17 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
Future<void> connectHealthConnect() =>
|
Future<void> connectHealthConnect() =>
|
||||||
_connect(HealthProviderType.healthConnect);
|
_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.
|
/// Tears down the binding for [provider] and re-probes its status.
|
||||||
///
|
///
|
||||||
/// HealthKit / Health Connect expose no programmatic revocation to
|
/// HealthKit / Health Connect expose no programmatic revocation to
|
||||||
|
|
@ -207,6 +233,8 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
state = state.copyWith(healthConnectStatus: status);
|
state = state.copyWith(healthConnectStatus: status);
|
||||||
case HealthProviderType.enterpriseFhir:
|
case HealthProviderType.enterpriseFhir:
|
||||||
state = state.copyWith(fhirStatus: status);
|
state = state.copyWith(fhirStatus: status);
|
||||||
|
case HealthProviderType.googleHealth:
|
||||||
|
state = state.copyWith(googleHealthStatus: status);
|
||||||
case HealthProviderType.manualFallback:
|
case HealthProviderType.manualFallback:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,11 @@ class _IntegrationsSection extends ConsumerWidget {
|
||||||
_IntegrationRow(
|
_IntegrationRow(
|
||||||
icon: Icons.apple,
|
icon: Icons.apple,
|
||||||
title: 'Apple Health',
|
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,
|
status: state.appleHealthStatus,
|
||||||
busy: state.isRequestingPermissions,
|
busy: state.isRequestingPermissions,
|
||||||
onConnect: state.isRequestingPermissions
|
onConnect: state.isRequestingPermissions
|
||||||
|
|
@ -337,8 +341,11 @@ class _IntegrationsSection extends ConsumerWidget {
|
||||||
_IntegrationRow(
|
_IntegrationRow(
|
||||||
icon: Icons.fingerprint,
|
icon: Icons.fingerprint,
|
||||||
title: 'Health Connect (Samsung & Pixel)',
|
title: 'Health Connect (Samsung & Pixel)',
|
||||||
subtitle:
|
subtitle: state.nativeHealthUnsupported
|
||||||
'Federated biometrics from Samsung Health & Pixel sensors',
|
? '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,
|
status: state.healthConnectStatus,
|
||||||
busy: state.isRequestingPermissions,
|
busy: state.isRequestingPermissions,
|
||||||
onConnect: state.isRequestingPermissions
|
onConnect: state.isRequestingPermissions
|
||||||
|
|
@ -350,6 +357,21 @@ class _IntegrationsSection extends ConsumerWidget {
|
||||||
.disconnectProvider(HealthProviderType.healthConnect),
|
.disconnectProvider(HealthProviderType.healthConnect),
|
||||||
),
|
),
|
||||||
const Divider(),
|
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(
|
_IntegrationRow(
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
title: 'SMART on FHIR',
|
title: 'SMART on FHIR',
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
@ -1075,7 +1075,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,11 @@ dependencies:
|
||||||
health: 13.3.1
|
health: 13.3.1
|
||||||
flutter_local_notifications: ^17.1.0
|
flutter_local_notifications: ^17.1.0
|
||||||
|
|
||||||
|
crypto: ^3.0.3
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
supabase_flutter: ^2.5.0
|
supabase_flutter: ^2.5.0
|
||||||
|
url_launcher: ^6.3.0
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
sqlite3: ^3.4.0
|
sqlite3: ^3.4.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/cloud_health/google_health_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('googleHealthWeightMetrics', () {
|
||||||
|
final since = DateTime(2026, 10, 1);
|
||||||
|
final until = DateTime(2026, 10, 17, 12);
|
||||||
|
|
||||||
|
test('converts kg samples to pounds and honors the window', () {
|
||||||
|
final samples = googleHealthWeightMetrics(
|
||||||
|
{
|
||||||
|
'dataPoints': [
|
||||||
|
{
|
||||||
|
'weight': {'unit': 'kg', 'value': 80.0},
|
||||||
|
'instantaneousTime': '2026-10-16T07:15:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'weight': {'unit': 'kg', 'value': 81.5},
|
||||||
|
'timeInterval': {'startTime': '2026-10-15T07:05:00Z'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'weight': {'unit': 'kg', 'value': 90.0},
|
||||||
|
'instantaneousTime': '2025-01-01T00:00:00Z', // outside window
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'instantaneousTime': '2026-10-14T07:00:00Z', // missing weight
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samples, hasLength(2));
|
||||||
|
expect(samples.first.value, closeTo(80.0 * kgToLbs, 1e-9));
|
||||||
|
expect(samples.first.unit, 'lb');
|
||||||
|
expect(samples.first.timestamp.isUtc, isTrue);
|
||||||
|
expect(samples.first.timestamp.year, 2026);
|
||||||
|
expect(samples.last.value, closeTo(81.5 * kgToLbs, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes pound samples through and sorts newest first', () {
|
||||||
|
final samples = googleHealthWeightMetrics(
|
||||||
|
{
|
||||||
|
'dataPoints': [
|
||||||
|
{
|
||||||
|
'weight': {'unit': 'lb', 'value': 175.4},
|
||||||
|
'instantaneousTime': '2026-10-10T06:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'weight': {'unit': 'lb', 'value': 178.0},
|
||||||
|
'instantaneousTime': '2026-10-12T08:30:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samples, hasLength(2));
|
||||||
|
expect(samples.first.value, 178.0); // newest first
|
||||||
|
expect(samples.last.value, 175.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates malformed payloads without throwing', () {
|
||||||
|
expect(
|
||||||
|
googleHealthWeightMetrics(null, since: since, until: until),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
googleHealthWeightMetrics(
|
||||||
|
{'dataPoints': 'nope'},
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('googleHealthActiveEnergyMetrics', () {
|
||||||
|
final since = DateTime(2026, 10, 17);
|
||||||
|
final until = DateTime(2026, 10, 18);
|
||||||
|
|
||||||
|
test('passes kcal values through', () {
|
||||||
|
final samples = googleHealthActiveEnergyMetrics(
|
||||||
|
{
|
||||||
|
'dataPoints': [
|
||||||
|
{
|
||||||
|
'activeEnergyBurned': {'unit': 'kcal', 'value': 420.5},
|
||||||
|
'instantaneousTime': '2026-10-17T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samples.single.value, 420.5);
|
||||||
|
expect(samples.single.unit, 'kcal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns nothing for missing data or out-of-window samples', () {
|
||||||
|
expect(
|
||||||
|
googleHealthActiveEnergyMetrics(null, since: since, until: until),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
googleHealthActiveEnergyMetrics(
|
||||||
|
{
|
||||||
|
'dataPoints': [
|
||||||
|
{
|
||||||
|
'activeEnergyBurned': {'unit': 'kcal', 'value': 1},
|
||||||
|
'instantaneousTime': '2026-01-01T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
since: since,
|
||||||
|
until: until,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -122,13 +122,15 @@ void main() {
|
||||||
expect(find.text('Health Integrations'), findsOneWidget);
|
expect(find.text('Health Integrations'), findsOneWidget);
|
||||||
expect(find.text('Apple Health'), findsOneWidget);
|
expect(find.text('Apple Health'), findsOneWidget);
|
||||||
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
|
expect(find.text('Health Connect (Samsung & Pixel)'), findsOneWidget);
|
||||||
|
expect(find.text('Google Health'), findsOneWidget);
|
||||||
expect(find.text('SMART on FHIR'), findsOneWidget);
|
expect(find.text('SMART on FHIR'), findsOneWidget);
|
||||||
|
|
||||||
// Native providers are unsupported on desktop; the FHIR connector is
|
// Native providers and the (web-stubbed) Google Health connector are
|
||||||
// not provisioned yet — every Connect affordance is disabled.
|
// unsupported on desktop; the FHIR connector is not provisioned yet —
|
||||||
expect(find.text('Unsupported'), findsNWidgets(2));
|
// every Connect affordance is disabled.
|
||||||
|
expect(find.text('Unsupported'), findsNWidgets(3));
|
||||||
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
|
final connectButtons = find.widgetWithText(FilledButton, 'Connect');
|
||||||
expect(connectButtons, findsNWidgets(3));
|
expect(connectButtons, findsNWidgets(4));
|
||||||
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
|
for (final button in tester.widgetList<FilledButton>(connectButtons)) {
|
||||||
expect(button.onPressed, isNull);
|
expect(button.onPressed, isNull);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue