import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/features/health_sync/domain/entities/biometric_entry.dart'; import 'package:glp1_companion/features/health_sync/domain/repositories/health_sync_repository.dart'; import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart'; import 'package:glp1_companion/features/nutrition/domain/entities/food_log_entry.dart'; import 'package:glp1_companion/features/nutrition/domain/entities/hydration_log_entry.dart'; import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart'; import 'package:glp1_companion/features/nutrition/domain/repositories/nutrition_repository.dart'; import 'package:glp1_companion/features/nutrition/presentation/controllers/nutrition_controller.dart'; import 'package:glp1_companion/features/nutrition/presentation/screens/nutrition_screen.dart'; /// In-memory [INutritionRepository] fake with auto-incrementing ids, matching /// the drift-backed behaviour the controller relies on. class _FakeNutritionRepository implements INutritionRepository { final List _foods = []; final List _waters = []; int _nextId = 1; @override Stream> watchFoodLogs({int limit = 500}) => Stream.value(_foods.take(limit).toList()); @override Future> getFoodLogs({ int limit = 500, DateTime? from, DateTime? to, }) async => _foods .where((entry) => (from == null || !entry.timestamp.isBefore(from)) && (to == null || entry.timestamp.isBefore(to))) .take(limit) .toList(); @override Future saveFoodLog(FoodLogEntry entry) async { final stored = entry.copyWith(id: entry.id ?? _nextId++); _foods.removeWhere((existing) => existing.id == stored.id); _foods.insert(0, stored); return stored; } @override Future deleteFoodLog(int id) async { _foods.removeWhere((entry) => entry.id == id); } @override Stream> watchHydrationLogs({int limit = 500}) => Stream.value(_waters.take(limit).toList()); @override Future> getHydrationLogs({ int limit = 500, DateTime? from, DateTime? to, }) async => _waters .where((entry) => (from == null || !entry.timestamp.isBefore(from)) && (to == null || entry.timestamp.isBefore(to))) .take(limit) .toList(); @override Future saveHydrationLog(HydrationLogEntry entry) async { final stored = entry.copyWith(id: entry.id ?? _nextId++); _waters.insert(0, stored); return stored; } @override Future deleteHydrationLog(int id) async { _waters.removeWhere((entry) => entry.id == id); } } /// Empty [IHealthSyncRepository] fake for the weight-tracker card. class _EmptyHealthSyncRepository implements IHealthSyncRepository { @override Stream> watchBiometrics({int limit = 500}) => Stream.value(const []); @override Future> getBiometrics({ int limit = 500, DateTime? from, DateTime? to, }) async => const []; @override Future saveBiometric(BiometricEntry entry) async => entry; @override Future deleteBiometric(int id) async {} @override Future getLatestBiometric() async => null; @override Future clearSyncedRange({ required BiometricSource source, required DateTime start, required DateTime end, }) async {} } void main() { late _FakeNutritionRepository repository; setUp(() => repository = _FakeNutritionRepository()); Future pumpScreen(WidgetTester tester) async { // A tall viewport keeps the protein card, hydration card, and quick-add // buttons visible without scrolling. tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( ProviderScope( overrides: [ nutritionRepositoryProvider.overrideWithValue(repository), healthSyncRepositoryProvider .overrideWithValue(_EmptyHealthSyncRepository()), ], child: const MaterialApp(home: NutritionScreen()), ), ); await tester.pumpAndSettle(); } /// First [LinearProgressIndicator] is protein, second is hydration. double proteinBar(WidgetTester tester) => tester .widgetList(find.byType(LinearProgressIndicator)) .first .value!; double hydrationBar(WidgetTester tester) => tester .widgetList(find.byType(LinearProgressIndicator)) .elementAt(1) .value!; testWidgets('progress bars react to a new protein log entry', (tester) async { await pumpScreen(tester); expect(find.text('0.0g / 100g goal'), findsOneWidget); expect(proteinBar(tester), 0); final container = ProviderScope.containerOf( tester.element(find.byType(NutritionScreen)), ); final logged = await container .read(nutritionControllerProvider.notifier) .logFood( mealType: MealType.breakfast, description: 'Greek yogurt bowl', proteinGrams: 40, calories: 220, ); expect(logged, isTrue); await tester.pumpAndSettle(); // 40 g of a 100 g target -> the meter and total update reactively. expect(find.text('40.0g / 100g goal'), findsOneWidget); expect(proteinBar(tester), closeTo(0.4, 1e-9)); expect(hydrationBar(tester), 0); }); testWidgets('quick-add water buttons advance the hydration meter', (tester) async { await pumpScreen(tester); expect(find.text('0 oz / 96 oz goal'), findsOneWidget); expect(hydrationBar(tester), 0); await tester.tap(find.text('+8.0 oz')); await tester.pumpAndSettle(); expect(find.text('8 oz / 96 oz goal'), findsOneWidget); expect(hydrationBar(tester), closeTo(8 / 96, 1e-9)); await tester.tap(find.text('+16.0 oz')); await tester.pumpAndSettle(); expect(find.text('24 oz / 96 oz goal'), findsOneWidget); expect(hydrationBar(tester), closeTo(24 / 96, 1e-9)); }); testWidgets('both meters saturate and surface the success chips', (tester) async { await pumpScreen(tester); final container = ProviderScope.containerOf( tester.element(find.byType(NutritionScreen)), ); final controller = container.read(nutritionControllerProvider.notifier); await controller.logFood( mealType: MealType.lunch, description: 'Chicken bowl', proteinGrams: 60, calories: 520, carbsGrams: 45, fatGrams: 18, ); await controller.logFood( mealType: MealType.dinner, description: 'Salmon plate', proteinGrams: 40, calories: 480, ); await controller.logWater(96); await tester.pumpAndSettle(); expect(proteinBar(tester), 1); expect(hydrationBar(tester), 1); // Only the protein card carries a "Goal met" chip; the hydration card // signals completion through its saturated (teal) meter. expect(find.text('Goal met'), findsOneWidget); expect(find.text('100.0g / 100g goal'), findsOneWidget); expect(find.text('96 oz / 96 oz goal'), findsOneWidget); }); }