438 lines
14 KiB
Dart
438 lines
14 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).
|
|
///
|
|
/// The fallback driver receives the encrypted drift cache as its
|
|
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
|
|
/// biometrics instead of returning nothing.
|
|
///
|
|
/// Overridable in tests with a fake gateway.
|
|
final healthGatewayProvider = Provider<IHealthGateway>(
|
|
(ref) => createHealthGateway(
|
|
localReader: DriftLocalBiometricReader(
|
|
ref.watch(healthSyncRepositoryProvider),
|
|
),
|
|
),
|
|
);
|
|
|
|
/// Adapts the drift-backed biometric cache ([IHealthSyncRepository]) to the
|
|
/// core [ILocalBiometricReader] contract, translating domain entries into
|
|
/// display-unit [HealthMetric] samples (lb / kcal).
|
|
class DriftLocalBiometricReader implements ILocalBiometricReader {
|
|
const DriftLocalBiometricReader(this._repository);
|
|
|
|
final IHealthSyncRepository _repository;
|
|
|
|
@override
|
|
Future<List<HealthMetric>> readWeightRange({
|
|
required DateTime start,
|
|
required DateTime end,
|
|
}) async {
|
|
final entries = await _repository.getBiometrics(from: start, to: end);
|
|
return [
|
|
for (final entry in entries)
|
|
HealthMetric(
|
|
timestamp: entry.timestamp,
|
|
value: entry.weightLbs,
|
|
unit: 'lb',
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<List<HealthMetric>> readEnergyRange({
|
|
required DateTime start,
|
|
required DateTime end,
|
|
}) async {
|
|
final entries = await _repository.getBiometrics(from: start, to: end);
|
|
return [
|
|
for (final entry in entries)
|
|
if (entry.activeCaloriesBurned != null)
|
|
HealthMetric(
|
|
timestamp: entry.timestamp,
|
|
value: entry.activeCaloriesBurned!,
|
|
unit: 'kcal',
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
); |