glp-companion/lib/core/platform/health_gateway.dart

285 lines
11 KiB
Dart

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;
/// Native health ecosystem backing the current device.
///
/// Kept in `core` (not the health_sync feature) so the platform layer never
/// imports feature code; the biometric controller maps this to the domain's
/// `BiometricSource` enum.
enum NativeHealthPlatform {
/// Apple HealthKit (iOS).
appleHealth,
/// Google Health Connect (Android).
healthConnect,
}
/// Specific health ecosystem a caller wants to address (Phase 3B contract,
/// architecture §4.7.2). [samsungHealth] federates through Health Connect on
/// Android; [enterpriseFhir] is the SMART on FHIR cloud connector (provisioning
/// pending); [manualFallback] is the always-available desktop/web mode.
enum HealthProviderType {
appleHealth,
healthConnect,
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
/// [IHealthGateway.checkStatus] for a [HealthProviderType].
enum HealthConnectionStatus {
/// Provider is supported but no permission decision exists yet.
notConfigured,
/// The OS health store explicitly refused (or revoked) access.
permissionDenied,
/// Entitlements + read permissions are granted.
connected,
/// The provider cannot run on this device/OS (e.g. HealthKit on Windows).
unsupportedPlatform,
}
/// Day-aggregated biometric snapshot returned by
/// [IHealthGateway.fetchLatestBiometrics] (architecture §4.7.2).
///
/// Every field is nullable: a platform may grant access to weight but not to
/// blood glucose (or vice versa), and the payload simply reports what it saw.
class HealthDataPayload {
const HealthDataPayload({
this.weightLbs,
this.activeCaloriesToday,
this.bloodGlucoseMgDl,
required this.timestamp,
required this.source,
});
/// Latest body-mass reading of the current calendar day, in pounds.
final double? weightLbs;
/// Summed active energy expenditure for the current calendar day, in kcal.
final double? activeCaloriesToday;
/// Latest blood-glucose reading of the current calendar day, in mg/dL
/// (mmol/L samples are normalized by the gateway).
final double? bloodGlucoseMgDl;
/// When the snapshot was assembled.
final DateTime timestamp;
/// Ecosystem the numbers came from.
final HealthProviderType source;
}
/// Scalar sample returned by the health gateway.
///
/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit
/// normalization (kg → lb, joules → kcal) happens inside the gateway so
/// consumers only ever deal in pounds and Calories.
class HealthMetric {
const HealthMetric({
required this.timestamp,
required this.value,
required this.unit,
});
/// When the sample was recorded.
final DateTime timestamp;
/// Normalized magnitude (pounds for weight, kilocalories for energy).
final double value;
/// Unit of [value] for display (e.g. `lb`, `kcal`).
final String unit;
}
/// Read-only access to biometrics that were logged locally on this device.
///
/// Implemented by the health_sync feature over the encrypted drift cache and
/// injected into the fallback gateway so Web/Windows/macOS builds serve
/// locally logged data instead of returning nothing. Declared in `core` to
/// keep the platform layer free of feature imports (architecture rule 2);
/// the dependency points inward: features implement core contracts.
abstract class ILocalBiometricReader {
/// Weight samples in pounds for `[start, end)`, newest first.
Future<List<HealthMetric>> readWeightRange({
required DateTime start,
required DateTime end,
});
/// Active energy burned (kcal) samples for `[start, end)`, newest first.
Future<List<HealthMetric>> readEnergyRange({
required DateTime start,
required DateTime end,
});
}
/// Cross-platform boundary over the OS health ecosystems (architecture rule
/// 2/3: platform-specific packages never leak past this interface).
///
/// * **Android / iOS:** implemented by `health_gateway_mobile.dart`, which
/// wraps the `health` package (Health Connect / HealthKit) behind
/// `kIsWeb` / `defaultTargetPlatform` guards.
/// * **Windows / macOS / Web:** implemented by `health_gateway_fallback.dart`,
/// a manual-entry mode that never touches platform channels; reads are
/// served from the drift-backed cache through an injected
/// [ILocalBiometricReader].
abstract class IHealthGateway {
/// Whether this device can read/write native health data. `false` on
/// 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;
/// Requests read permission for weight and active energy; returns whether
/// access was granted. Always `false` in fallback mode.
Future<bool> requestPermissions();
/// Weight samples in pounds for `[start, end)`, newest first.
///
/// Returns an empty list (never throws) in fallback mode or when the OS
/// denies access.
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
required DateTime end,
});
/// Active energy burned (kcal) samples for `[start, end)`, newest first.
///
/// Returns an empty list (never throws) in fallback mode or when the OS
/// denies access.
Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start,
required DateTime end,
});
/// Writes a weight measurement back to the native store; returns whether
/// the platform accepted it (always `false` in fallback mode).
Future<bool> writeWeight({
required double weightLbs,
required DateTime timestamp,
});
// ---------------------------------------------------------------------
// Phase 3B contract (architecture §4.7.2). Default implementations are
// derived from the primitives above so hand-written fakes and future
// cloud drivers only override what they need.
// ---------------------------------------------------------------------
/// The [HealthProviderType] this device's driver actually binds to, or
/// `null` in fallback mode (Web/Windows/macOS).
HealthProviderType? get activeProvider {
if (!supportsNativeHealth) return null;
final platform = nativePlatform;
if (platform == NativeHealthPlatform.appleHealth) {
return HealthProviderType.appleHealth;
}
if (platform == NativeHealthPlatform.healthConnect) {
return HealthProviderType.healthConnect;
}
return null;
}
/// Verifies current OS entitlement and read permissions for [provider].
///
/// Default: `manualFallback` is always available, `enterpriseFhir` is not
/// provisioned yet, native providers map through [activeProvider].
Future<HealthConnectionStatus> checkStatus(
HealthProviderType provider) async {
if (provider == HealthProviderType.manualFallback) {
return HealthConnectionStatus.connected;
}
if (provider == HealthProviderType.enterpriseFhir) {
return HealthConnectionStatus.notConfigured;
}
final active = activeProvider;
if (active == null) {
return HealthConnectionStatus.unsupportedPlatform;
}
return provider == active
? HealthConnectionStatus.notConfigured
: HealthConnectionStatus.unsupportedPlatform;
}
/// Triggers the native OS permission request prompt for [provider].
///
/// Default: manual entry needs no grant; native providers route through
/// [requestPermissions]; mismatched providers are refused.
Future<bool> requestAuthorization(HealthProviderType provider) async {
if (provider == HealthProviderType.manualFallback) return true;
if (provider != activeProvider) return false;
return requestPermissions();
}
/// Queries biometrics aggregated across the current calendar day.
///
/// Default: composes [fetchWeightHistory] (latest) and
/// [fetchActiveEnergyBurned] (sum); drivers may override to add types such
/// as blood glucose. Returns `null` when nothing could be read.
Future<HealthDataPayload?> fetchLatestBiometrics() async {
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);
if (weights.isEmpty && energies.isEmpty) return null;
var energyTotal = 0.0;
for (final sample in energies) {
energyTotal += sample.value;
}
return HealthDataPayload(
weightLbs: weights.isEmpty ? null : weights.first.value,
activeCaloriesToday: energies.isEmpty ? null : energyTotal,
timestamp: now,
source: activeProvider ?? HealthProviderType.manualFallback,
);
}
/// Revokes active session bindings or tears down local connections for
/// [provider]. Default: no-op (neither HealthKit nor Health Connect
/// exposes programmatic revocation to third-party apps — drivers may clear
/// cached readiness so the next [checkStatus] re-probes).
Future<void> disconnect(HealthProviderType provider) async {
debugPrint(
'HealthGateway: disconnect($provider) ignored - revoke access in the '
'system health settings.',
);
}
}
/// Platform-appropriate factory: native `health`-backed gateway on
/// Android/iOS, manual-entry fallback on Web/Windows/macOS.
///
/// [localReader] backs fallback reads with the locally logged biometric
/// cache; mobile platforms ignore it and query the native store directly.
IHealthGateway createHealthGateway({
ILocalBiometricReader? localReader,
List<ICloudHealthConnector> cloudConnectors = const [],
}) =>
impl.makeHealthGateway(
localReader: localReader,
cloudConnectors: cloudConnectors,
);