261 lines
9.6 KiB
Dart
261 lines
9.6 KiB
Dart
import 'package:flutter/foundation.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,
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// 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}) =>
|
|
impl.makeHealthGateway(localReader: localReader);
|