glp-companion/lib/features/nutrition/presentation/controllers/nutrition_controller.dart

251 lines
8.1 KiB
Dart

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<INutritionRepository>(
(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<FoodLogEntry> foodLogs;
/// Hydration entries logged on [day], newest first.
final List<HydrationLogEntry> 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<MealType, List<FoodLogEntry>> get mealsByType {
final grouped = <MealType, List<FoodLogEntry>>{};
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<NutritionState> {
/// 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<NutritionState> 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<bool> 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<bool> 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<bool> 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<bool> 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<void> 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<NutritionState> _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<void> _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, NutritionState>(
NutritionController.new,
);