glp-companion/lib/features/health_sync/presentation/controllers/biometric_controller.dart

388 lines
13 KiB
Dart

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<IHealthSyncRepository>(
(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<IHealthGateway>(
(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<BiometricEntry> 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<BiometricEntry>? 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<BiometricState> {
/// 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<BiometricState> build() => _load();
/// Persists a manually entered weight sample and refreshes trends.
Future<bool> 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<bool> 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<bool> 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 = <BiometricEntry>[];
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<void> 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<BiometricState> _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<void> _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<BiometricState> next) => state = next;
/// Sums active-energy samples per local calendar day (day-start anchors),
/// returned in chronological order.
Map<DateTime, double> _groupEnergyByDay(List<HealthMetric> energies) {
final perDay = <DateTime, double>{};
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<HealthMetric> energies,
required List<BiometricEntry> savedWeights,
required List<BiometricEntry> 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, BiometricState>(
BiometricController.new,
);