import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/utils/date_time_utils.dart'; import '../../data/repositories/nutrition_repository.dart'; import '../../domain/entities/food_log_entry.dart'; import '../../domain/entities/hydration_log_entry.dart'; import '../../domain/entities/meal_type.dart'; import '../../domain/repositories/nutrition_repository.dart'; /// Application-wide nutrition repository (drift-backed). /// /// Overridable in widget/unit tests with an in-memory fake. final nutritionRepositoryProvider = Provider( (ref) => NutritionRepository(ref.watch(appDatabaseProvider)), ); /// Immutable snapshot of the daily nutrition/hydration tracking state. class NutritionState { const NutritionState({ required this.foodLogs, required this.hydrationLogs, required this.day, required this.proteinTargetGrams, required this.hydrationTargetOz, }); /// Food entries logged on [day], newest first. final List foodLogs; /// Hydration entries logged on [day], newest first. final List hydrationLogs; /// Local calendar day the snapshot covers. final DateTime day; /// Daily protein goal in grams (Phase 3 default: 100 g). final double proteinTargetGrams; /// Daily hydration goal in fluid ounces (Phase 3 default: 96 oz). final double hydrationTargetOz; /// Sum of [FoodLogEntry.proteinGrams] over [foodLogs]. double get totalProteinGrams => foodLogs.fold(0, (sum, entry) => sum + entry.proteinGrams); /// Sum of [FoodLogEntry.calories] over [foodLogs]. int get totalCalories => foodLogs.fold(0, (sum, entry) => sum + entry.calories); /// Sum of carbs over [foodLogs] when at least one entry recorded carbs. double? get totalCarbsGrams { double? total; for (final entry in foodLogs) { final carbs = entry.carbsGrams; if (carbs == null) continue; total = (total ?? 0) + carbs; } return total; } /// Sum of fat over [foodLogs] when at least one entry recorded fat. double? get totalFatGrams { double? total; for (final entry in foodLogs) { final fat = entry.fatGrams; if (fat == null) continue; total = (total ?? 0) + fat; } return total; } /// Sum of [HydrationLogEntry.volumeOz] over [hydrationLogs]. double get totalWaterOz => hydrationLogs.fold(0, (sum, entry) => sum + entry.volumeOz); /// Protein progress in `0..1+` (may exceed 1 when the goal is overshot). double get proteinProgress => proteinTargetGrams <= 0 ? 0 : totalProteinGrams / proteinTargetGrams; /// Hydration progress in `0..1+`. double get hydrationProgress => hydrationTargetOz <= 0 ? 0 : totalWaterOz / hydrationTargetOz; /// Today's meals grouped by slot in canonical order (breakfast → snack); /// empty slots are omitted. Map> get mealsByType { final grouped = >{}; for (final type in MealType.values) { final entries = foodLogs.where((entry) => entry.mealType == type); if (entries.isNotEmpty) grouped[type] = entries.toList(growable: false); } return grouped; } } /// Orchestrates the nutrition feature: loads today's food/hydration logs, /// derives protein and water totals against the daily targets, and persists /// quick-add / dialog entries. class NutritionController extends AsyncNotifier { /// Phase 3 default daily protein goal (lean-mass preservation target). static const double defaultProteinTargetGrams = 100; /// Phase 3 default daily hydration goal. static const double defaultHydrationTargetOz = 96; @override Future build() async { final repository = ref.watch(nutritionRepositoryProvider); return _load(repository, day: DateTime.now()); } /// Persists a meal entry and refreshes the daily snapshot. /// /// Returns whether the write succeeded; failures keep the last good state. Future logFood({ required MealType mealType, required String description, required double proteinGrams, required int calories, double? carbsGrams, double? fatGrams, DateTime? timestamp, }) async { final trimmed = description.trim(); if (trimmed.isEmpty || proteinGrams < 0 || calories < 0) return false; try { await ref.read(nutritionRepositoryProvider).saveFoodLog( FoodLogEntry( timestamp: timestamp ?? DateTime.now(), mealType: mealType, description: trimmed, proteinGrams: proteinGrams, calories: calories, carbsGrams: carbsGrams, fatGrams: fatGrams, ), ); await _refresh(); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'saveFoodLog failed'); return false; } } /// Removes a food entry by database [id]. Future deleteFood(int id) async { try { await ref.read(nutritionRepositoryProvider).deleteFoodLog(id); await _refresh(); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'deleteFoodLog failed'); return false; } } /// Quick-adds [ounces] of water (e.g. the +8 / +16 / +24 oz buttons). Future logWater(double ounces, {DateTime? timestamp}) async { if (ounces <= 0) return false; try { await ref.read(nutritionRepositoryProvider).saveHydrationLog( HydrationLogEntry( timestamp: timestamp ?? DateTime.now(), volumeOz: ounces, ), ); await _refresh(); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'saveHydrationLog failed'); return false; } } /// Removes a hydration entry by database [id]. Future deleteWater(int id) async { try { await ref.read(nutritionRepositoryProvider).deleteHydrationLog(id); await _refresh(); return true; } catch (error, stackTrace) { _recordError(error, stackTrace, 'deleteHydrationLog failed'); return false; } } /// Reloads the snapshot after a failed build (error-state retry action). Future retry() async { state = const AsyncLoading(); try { state = AsyncData( await _load(ref.read(nutritionRepositoryProvider), day: DateTime.now()), ); } catch (error, stackTrace) { state = AsyncError(error, stackTrace); } } /// Reloads today's food and hydration logs from the repository. Future _load( INutritionRepository repository, { required DateTime day, }) async { final from = startOfDay(day); final to = endOfDay(day); final foodLogs = await repository.getFoodLogs(from: from, to: to); final hydrationLogs = await repository.getHydrationLogs(from: from, to: to); return NutritionState( foodLogs: foodLogs, hydrationLogs: hydrationLogs, day: day, proteinTargetGrams: defaultProteinTargetGrams, hydrationTargetOz: defaultHydrationTargetOz, ); } /// 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() async { try { state = AsyncData( await _load(ref.read(nutritionRepositoryProvider), day: DateTime.now()), ); } catch (error, stackTrace) { _recordError(error, stackTrace, 'nutrition 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) { state = AsyncError(error, stackTrace); } else { state = AsyncData(current); } } } /// Exposes the [NutritionController] to the widget tree. final nutritionControllerProvider = AsyncNotifierProvider( NutritionController.new, );