251 lines
8.1 KiB
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,
|
|
); |