glp-companion/lib/features/settings/presentation/controllers/integrations_controller.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,
);