255 lines
9.9 KiB
Dart
255 lines
9.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/platform/health_gateway.dart';
|
|
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
|
|
|
|
/// Immutable snapshot of the integrations state shown on the Settings
|
|
/// screen (Phase 3B: dynamic connection status per provider).
|
|
///
|
|
/// Native health providers are probed through the [IHealthGateway] on every
|
|
/// connect/disconnect round-trip; the SMART on FHIR enterprise connector
|
|
/// reports `notConfigured` until its OAuth2 backend is provisioned.
|
|
class IntegrationsState {
|
|
const IntegrationsState({
|
|
this.appleHealthStatus = HealthConnectionStatus.notConfigured,
|
|
this.healthConnectStatus = HealthConnectionStatus.notConfigured,
|
|
this.fhirStatus = HealthConnectionStatus.notConfigured,
|
|
this.googleHealthStatus = HealthConnectionStatus.notConfigured,
|
|
this.isRequestingPermissions = false,
|
|
this.errorMessage,
|
|
});
|
|
|
|
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
|
static const Object _keep = Object();
|
|
|
|
/// Apple HealthKit connection status (iOS).
|
|
final HealthConnectionStatus appleHealthStatus;
|
|
|
|
/// Google Health Connect connection status (Android; federates Samsung
|
|
/// Health and Pixel sensor feeds).
|
|
final HealthConnectionStatus healthConnectStatus;
|
|
|
|
/// SMART on FHIR enterprise connector status (cloud OAuth2 / REST).
|
|
final HealthConnectionStatus fhirStatus;
|
|
|
|
/// Google Health cloud connector status (OAuth2 + REST; desktop/web)..
|
|
final HealthConnectionStatus googleHealthStatus;
|
|
|
|
/// `true` while a native permission round-trip is in flight.
|
|
final bool isRequestingPermissions;
|
|
|
|
/// Last permission/persistence failure, surfaced inline.
|
|
final String? errorMessage;
|
|
|
|
/// Whether Apple Health is granted on this device.
|
|
bool get appleHealthConnected =>
|
|
appleHealthStatus == HealthConnectionStatus.connected;
|
|
|
|
/// Whether Health Connect is granted on this device.
|
|
bool get healthConnectConnected =>
|
|
healthConnectStatus == HealthConnectionStatus.connected;
|
|
|
|
/// Whether the Google Health cloud connector holds a valid OAuth grant.
|
|
bool get googleHealthConnected =>
|
|
googleHealthStatus == HealthConnectionStatus.connected;
|
|
|
|
/// True when the device offers no native health SDK at all (Web/Windows/
|
|
/// macOS fallback driver) — the UI disables native connect buttons.
|
|
bool get nativeHealthUnsupported =>
|
|
appleHealthStatus == HealthConnectionStatus.unsupportedPlatform &&
|
|
healthConnectStatus == HealthConnectionStatus.unsupportedPlatform;
|
|
|
|
IntegrationsState copyWith({
|
|
HealthConnectionStatus? appleHealthStatus,
|
|
HealthConnectionStatus? healthConnectStatus,
|
|
HealthConnectionStatus? fhirStatus,
|
|
HealthConnectionStatus? googleHealthStatus,
|
|
bool? isRequestingPermissions,
|
|
Object? errorMessage = _keep,
|
|
}) =>
|
|
IntegrationsState(
|
|
appleHealthStatus: appleHealthStatus ?? this.appleHealthStatus,
|
|
healthConnectStatus: healthConnectStatus ?? this.healthConnectStatus,
|
|
fhirStatus: fhirStatus ?? this.fhirStatus,
|
|
googleHealthStatus: googleHealthStatus ?? this.googleHealthStatus,
|
|
isRequestingPermissions:
|
|
isRequestingPermissions ?? this.isRequestingPermissions,
|
|
errorMessage: identical(errorMessage, _keep)
|
|
? this.errorMessage
|
|
: errorMessage as String?,
|
|
);
|
|
}
|
|
|
|
/// Orchestrates the integrations section: probes per-provider connection
|
|
/// status through the cross-platform [IHealthGateway], runs native
|
|
/// authorization round-trips on connect, and tears down bindings on
|
|
/// disconnect. Degrades gracefully on platforms without native support.
|
|
class IntegrationsController extends Notifier<IntegrationsState> {
|
|
/// Disposal sentinel: Riverpod 2.x `Ref` has no `mounted` getter, so async
|
|
/// gaps guard on this flag instead of touching `state` after teardown.
|
|
var _disposed = false;
|
|
|
|
@override
|
|
IntegrationsState build() {
|
|
ref.onDispose(() => _disposed = true);
|
|
final gateway = ref.read(healthGatewayProvider);
|
|
// Synchronous first guess from the driver capability; the async probe
|
|
// below refines it with the real OS permission state.
|
|
final unsupported = gateway.supportsNativeHealth
|
|
? HealthConnectionStatus.notConfigured
|
|
: HealthConnectionStatus.unsupportedPlatform;
|
|
Future<void>.microtask(refreshStatuses);
|
|
return IntegrationsState(
|
|
appleHealthStatus: unsupported,
|
|
healthConnectStatus: unsupported,
|
|
errorMessage:
|
|
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
|
|
);
|
|
}
|
|
|
|
/// Human-readable capability notice for platforms without native health
|
|
/// SDKs (Web / Windows / macOS fallback driver).
|
|
static String? _fallbackNotice(IHealthGateway gateway) {
|
|
if (gateway.supportsNativeHealth) return null;
|
|
return 'Native health SDKs are unavailable on this platform — '
|
|
'HealthGateway runs in manual-entry mode.';
|
|
}
|
|
|
|
/// Re-probes the connection status of every provider row.
|
|
Future<void> refreshStatuses() async {
|
|
final gateway = ref.read(healthGatewayProvider);
|
|
final apple = await gateway.checkStatus(HealthProviderType.appleHealth);
|
|
if (_disposed) return;
|
|
final connect = await gateway.checkStatus(HealthProviderType.healthConnect);
|
|
if (_disposed) return;
|
|
final fhir = await gateway.checkStatus(HealthProviderType.enterpriseFhir);
|
|
if (_disposed) return;
|
|
final googleHealth = await gateway.checkStatus(HealthProviderType.googleHealth);
|
|
if (_disposed) return;
|
|
state = state.copyWith(
|
|
appleHealthStatus: apple,
|
|
healthConnectStatus: connect,
|
|
fhirStatus: fhir,
|
|
googleHealthStatus: googleHealth,
|
|
);
|
|
}
|
|
|
|
/// Runs the native authorization prompt for Apple Health (iOS); a granted
|
|
/// round-trip flips the row to `connected`, denial records a message.
|
|
Future<void> connectAppleHealth() =>
|
|
_connect(HealthProviderType.appleHealth);
|
|
|
|
/// Runs the native authorization prompt for Health Connect (Android).
|
|
Future<void> connectHealthConnect() =>
|
|
_connect(HealthProviderType.healthConnect);
|
|
|
|
/// Runs the Google Health cloud OAuth round trip (system browser + loopback
|
|
/// redirect); a granted round-trip immediately imports the account's
|
|
/// biometrics through the biometric controller.
|
|
Future<void> connectGoogleHealth() async {
|
|
await _connect(HealthProviderType.googleHealth);
|
|
if (_disposed || !state.googleHealthConnected) return;
|
|
unawaited(
|
|
ref.read(biometricControllerProvider.notifier).syncFromHealth(),
|
|
);
|
|
}
|
|
|
|
/// Tears down the binding for [provider] and re-probes its status.
|
|
///
|
|
/// HealthKit / Health Connect expose no programmatic revocation to
|
|
/// third-party apps, so the gateway clears cached state and the user
|
|
/// completes revocation in system settings; the re-probe reflects either
|
|
/// outcome.
|
|
Future<void> disconnectProvider(HealthProviderType provider) async {
|
|
final gateway = ref.read(healthGatewayProvider);
|
|
try {
|
|
await gateway.disconnect(provider);
|
|
if (_disposed) return;
|
|
_applyStatus(provider, await gateway.checkStatus(provider));
|
|
} catch (error, stackTrace) {
|
|
debugPrintStack(
|
|
stackTrace: stackTrace,
|
|
label: 'health disconnect failed',
|
|
);
|
|
if (!_disposed) {
|
|
state = state.copyWith(errorMessage: 'Could not disconnect: $error');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clears the transient error banner.
|
|
void clearError() {
|
|
if (state.errorMessage != null) {
|
|
state = state.copyWith(errorMessage: null);
|
|
}
|
|
}
|
|
|
|
Future<void> _connect(HealthProviderType provider) async {
|
|
state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
|
|
try {
|
|
final granted =
|
|
await ref.read(healthGatewayProvider).requestAuthorization(provider);
|
|
if (_disposed) return;
|
|
_applyStatus(
|
|
provider,
|
|
granted
|
|
? HealthConnectionStatus.connected
|
|
: HealthConnectionStatus.permissionDenied,
|
|
);
|
|
state = state.copyWith(
|
|
isRequestingPermissions: false,
|
|
errorMessage: granted
|
|
? null
|
|
: provider == HealthProviderType.googleHealth
|
|
// OAuth round trip (SDK on Android, browser loopback on
|
|
// desktop): nothing to enable in device settings — the flow
|
|
// was cancelled or the redirect never completed.
|
|
? 'Google Health sign-in was cancelled or did not complete. '
|
|
'Tap Connect to try again.'
|
|
: 'Health permissions were denied. Enable them in your device '
|
|
'settings to sync biometrics.',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
debugPrintStack(
|
|
stackTrace: stackTrace,
|
|
label: 'health permission request failed',
|
|
);
|
|
if (_disposed) return;
|
|
state = state.copyWith(
|
|
isRequestingPermissions: false,
|
|
errorMessage: 'Could not request health permissions: $error',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Routes a provider status update to the matching state field (Samsung
|
|
/// Health shares the Health Connect row because it federates through it).
|
|
void _applyStatus(
|
|
HealthProviderType provider,
|
|
HealthConnectionStatus status,
|
|
) {
|
|
switch (provider) {
|
|
case HealthProviderType.appleHealth:
|
|
state = state.copyWith(appleHealthStatus: status);
|
|
case HealthProviderType.healthConnect:
|
|
case HealthProviderType.samsungHealth:
|
|
state = state.copyWith(healthConnectStatus: status);
|
|
case HealthProviderType.enterpriseFhir:
|
|
state = state.copyWith(fhirStatus: status);
|
|
case HealthProviderType.googleHealth:
|
|
state = state.copyWith(googleHealthStatus: status);
|
|
case HealthProviderType.manualFallback:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Exposes the [IntegrationsController] to the widget tree.
|
|
final integrationsControllerProvider =
|
|
NotifierProvider<IntegrationsController, IntegrationsState>(
|
|
IntegrationsController.new,
|
|
);
|