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 { /// 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.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 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 connectAppleHealth() => _connect(HealthProviderType.appleHealth); /// Runs the native authorization prompt for Health Connect (Android). Future 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 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 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 _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 : '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.new, );