import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/platform/health_gateway.dart'; import '../../../../core/utils/date_time_utils.dart'; import '../../data/repositories/health_sync_repository.dart'; import '../../domain/entities/biometric_entry.dart'; import '../../domain/repositories/health_sync_repository.dart'; /// Application-wide biometric repository (drift-backed). /// /// Overridable in widget/unit tests with an in-memory fake. final healthSyncRepositoryProvider = Provider( (ref) => HealthSyncRepository(ref.watch(appDatabaseProvider)), ); /// Cross-platform health boundary (HealthKit / Health Connect on mobile, /// manual-entry fallback on Web/Windows/macOS). /// /// Overridable in tests with a fake gateway. final healthGatewayProvider = Provider( (ref) => createHealthGateway(), ); /// Immutable snapshot of the weight/biometric tracking state. class BiometricState { const BiometricState({ required this.history, required this.evaluatedAt, this.isSyncing = false, this.statusMessage, this.error, }); /// Full biometric history, newest first. final List history; /// Timestamp at which the trend deltas were evaluated. final DateTime evaluatedAt; /// Whether a native health sync is currently in flight. final bool isSyncing; /// Human-readable outcome of the last sync/manual action (snackbar text). final String? statusMessage; /// Most recent persistence/gateway failure, cleared on the next success. final Object? error; /// Most recent biometric sample, or `null` on an empty history. BiometricEntry? get latest => history.isEmpty ? null : history.first; /// Most recent recorded weight, or `null` on an empty history. double? get latestWeightLbs => latest?.weightLbs; /// Most recent active-energy sample (kcal), or `null` when none exists. double? get latestActiveCaloriesBurned { for (final entry in history) { final calories = entry.activeCaloriesBurned; if (calories != null) return calories; } return null; } /// Weight change (lbs) between the newest sample and the most recent /// sample at least [days] old; `null` when the history does not reach back /// that far. Negative values mean weight loss. double? weightDeltaOver(int days, {DateTime? now}) { final newest = latestWeightLbs; if (newest == null) return null; final cutoff = (now ?? evaluatedAt).subtract(Duration(days: days)); double? baseline; for (final entry in history) { if (!entry.timestamp.isAfter(cutoff)) { // Newest-first ordering: the first entry at/before the cutoff is the // closest baseline to it. baseline = entry.weightLbs; break; } } if (baseline == null) return null; return newest - baseline; } /// Weight change over the trailing 7 days. double? get weight7DayDelta => weightDeltaOver(7); /// Weight change over the trailing 30 days. double? get weight30DayDelta => weightDeltaOver(30); BiometricState copyWith({ List? history, DateTime? evaluatedAt, bool? isSyncing, String? statusMessage, Object? error, bool clearError = false, bool clearStatus = false, }) => BiometricState( history: history ?? this.history, evaluatedAt: evaluatedAt ?? this.evaluatedAt, isSyncing: isSyncing ?? this.isSyncing, statusMessage: clearStatus ? null : statusMessage ?? this.statusMessage, error: clearError ? null : error ?? this.error, ); } /// Orchestrates the health-sync feature: persists manual biometric entries, /// derives 7/30-day weight trends, and imports weight + active-energy data /// through the platform [IHealthGateway] (HealthKit / Health Connect) with a /// graceful manual-entry fallback on Web/Windows/macOS. class BiometricController extends AsyncNotifier { /// How far back a native sync imports samples. static const Duration syncWindow = Duration(days: 90); /// Maximum number of history rows kept in the presentation state. static const int historyLimit = 365; @override Future build() => _load(); /// Persists a manually entered weight sample and refreshes trends. Future logWeight({ required double weightLbs, DateTime? timestamp, }) async { if (weightLbs <= 0) return false; try { final saved = await ref.read(healthSyncRepositoryProvider).saveBiometric( BiometricEntry( timestamp: timestamp ?? DateTime.now(), weightLbs: weightLbs, source: BiometricSource.manual, ), ); await _refresh( statusMessage: 'Logged ${saved.weightLbs.toStringAsFixed(1)} lb', ); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'saveBiometric failed'); return false; } } /// Removes a biometric sample by database [id]. Future deleteEntry(int id) async { try { await ref.read(healthSyncRepositoryProvider).deleteBiometric(id); await _refresh(); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'deleteBiometric failed'); return false; } } /// Imports weight and active-energy samples from the native health store /// over the trailing [window] and persists them idempotently (the previous /// import for the same source/window is replaced, not duplicated). /// /// Returns `false` when the platform is unsupported (Web/Windows/macOS — /// callers should open the manual-entry dialog instead), when permission /// is denied, or when the sync throws; the reason is reflected in /// [BiometricState.statusMessage]. Future syncFromHealth({Duration window = syncWindow}) async { final gateway = ref.read(healthGatewayProvider); if (!gateway.supportsNativeHealth) { await _refresh( statusMessage: 'Native health sync is unavailable here - use manual entry.', ); return false; } _setState( AsyncData( state.valueOrNull?.copyWith(isSyncing: true) ?? BiometricState( history: const [], evaluatedAt: DateTime.now(), isSyncing: true, ), ), ); try { final granted = await gateway.requestPermissions(); if (!granted) { await _refresh( statusMessage: 'Health permissions were denied.', error: StateError('Health permissions denied'), ); return false; } final now = DateTime.now(); final start = now.subtract(window); final weights = await gateway.fetchWeightHistory(start: start, end: now); final energies = await gateway.fetchActiveEnergyBurned(start: start, end: now); final source = switch (gateway.nativePlatform) { NativeHealthPlatform.appleHealth => BiometricSource.appleHealth, NativeHealthPlatform.healthConnect => BiometricSource.healthConnect, null => BiometricSource.cloudSync, }; final repository = ref.read(healthSyncRepositoryProvider); // Idempotent re-sync: drop the previous import for this source/window. await repository.clearSyncedRange( source: source, start: start, end: now, ); final savedWeights = []; for (final metric in weights) { savedWeights.add( await repository.saveBiometric( BiometricEntry( timestamp: metric.timestamp, weightLbs: metric.value, source: source, ), ), ); } final energyDays = _groupEnergyByDay(energies); var energyDaysStored = 0; for (final day in energyDays.entries) { final entry = _buildEnergyEntry( dayStart: day.key, kcal: day.value, energies: energies, savedWeights: savedWeights, fallbackHistory: state.valueOrNull?.history ?? const [], source: source, ); if (entry == null) continue; await repository.saveBiometric(entry); energyDaysStored++; } await _refresh( statusMessage: 'Synced ${weights.length} weight sample(s), ' '$energyDaysStored day(s) of active energy.', ); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'syncFromHealth failed'); return false; } } /// Reloads the snapshot after a failed build (error-state retry action). Future retry() async { _setState(const AsyncLoading()); try { _setState(AsyncData(await _load())); } catch (error, stackTrace) { _setState(AsyncError(error, stackTrace)); } } /// Reloads the biometric history from the repository. Future _load({String? statusMessage, Object? error}) async { final history = await ref .read(healthSyncRepositoryProvider) .getBiometrics(limit: historyLimit); return BiometricState( history: history, evaluatedAt: DateTime.now(), statusMessage: statusMessage, error: error, ); } /// Replaces the current state with a fresh snapshot; failures keep the /// previous data so the UI never flashes empty after a rejected write. Future _refresh({String? statusMessage, Object? error}) async { try { _setState(AsyncData( await _load(statusMessage: statusMessage, error: error), )); } catch (loadError, stackTrace) { _recordError(loadError, stackTrace, 'biometric snapshot refresh failed'); } } /// Attaches a non-destructive error to the last known good state (or as an /// [AsyncError] when nothing has loaded yet). void _recordError(Object error, StackTrace stackTrace, String label) { debugPrintStack(stackTrace: stackTrace, label: '$label failed'); final current = state.valueOrNull; if (current == null) { _setState(AsyncError(error, stackTrace)); } else { _setState(AsyncData(current.copyWith(error: error))); } } void _setState(AsyncValue next) => state = next; /// Sums active-energy samples per local calendar day (day-start anchors), /// returned in chronological order. Map _groupEnergyByDay(List energies) { final perDay = {}; for (final metric in energies) { final day = startOfDay(metric.timestamp); perDay[day] = (perDay[day] ?? 0) + metric.value; } final entries = perDay.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); return Map.fromEntries(entries); } /// Builds the entry carrying [kcal] for one day: attaches the total to /// that day's synced weight sample when one exists, otherwise creates a /// standalone sample anchored to the day's last energy reading using the /// nearest prior known weight. Returns `null` when no weight is known yet. BiometricEntry? _buildEnergyEntry({ required DateTime dayStart, required double kcal, required List energies, required List savedWeights, required List fallbackHistory, required BiometricSource source, }) { // Weight recorded on the same day (lists are newest-first). BiometricEntry? sameDay; for (final weight in savedWeights) { if (startOfDay(weight.timestamp) == dayStart) { sameDay ??= weight; } } if (sameDay != null) { return sameDay.copyWith(activeCaloriesBurned: kcal); } // Nearest prior weight within the synced window. final dayEnd = endOfDay(dayStart); BiometricEntry? prior; for (final weight in savedWeights) { if (weight.timestamp.isBefore(dayEnd) && (prior == null || weight.timestamp.isAfter(prior.timestamp))) { prior = weight; } } // Fall back to the cached local history (entries older than the window). if (prior == null) { for (final entry in fallbackHistory) { if (entry.timestamp.isBefore(dayEnd) && (prior == null || entry.timestamp.isAfter(prior.timestamp))) { prior = entry; } } } if (prior == null) return null; // Anchor the sample to the day's last energy reading inside the window. DateTime? anchor; for (final metric in energies) { if (startOfDay(metric.timestamp) == dayStart && (anchor == null || metric.timestamp.isAfter(anchor))) { anchor = metric.timestamp; } } return BiometricEntry( timestamp: anchor ?? dayStart, weightLbs: prior.weightLbs, activeCaloriesBurned: kcal, source: source, ); } } /// Exposes the [BiometricController] to the widget tree. final biometricControllerProvider = AsyncNotifierProvider( BiometricController.new, );