import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/platform/health_gateway.dart'; import '../../../../core/utils/date_time_utils.dart'; import '../../../health_sync/presentation/controllers/biometric_controller.dart'; import '../../../health_sync/presentation/widgets/log_weight_dialog.dart'; import '../../domain/entities/food_log_entry.dart'; import '../../domain/entities/hydration_log_entry.dart'; import '../../domain/entities/meal_type.dart'; import '../controllers/nutrition_controller.dart'; import '../widgets/log_food_dialog.dart'; /// GLP-1 nutrition dashboard: daily protein and hydration progress meters, /// quick-add water buttons, a meal-grouped food diary, and the weight /// tracker card with native health sync / manual-entry fallback. class NutritionScreen extends ConsumerWidget { const NutritionScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final nutrition = ref.watch(nutritionControllerProvider); return Scaffold( appBar: AppBar(title: const Text('Nutrition & Hydration')), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: nutrition.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => _ErrorView( error: error, onRetry: () => ref.read(nutritionControllerProvider.notifier).retry(), ), data: (state) => ListView( padding: const EdgeInsets.all(16), children: [ _ProteinCard(state: state), const SizedBox(height: 16), _HydrationCard(state: state), const SizedBox(height: 16), FilledButton.icon( onPressed: () => showLogFoodDialog(context), icon: const Icon(Icons.add_circle_outline), label: const Text('Log Food'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () => context.push('/food-search'), icon: const Icon(Icons.qr_code_scanner), label: const Text('Search Food / Scan Barcode'), ), const SizedBox(height: 16), const _WeightTrackerCard(), const SizedBox(height: 24), _FoodDiary(state: state), const SizedBox(height: 24), ], ), ), ), ), ); } } /// Interactive protein meter: today's total vs. the 100 g daily target plus /// calorie/carb/fat roll-ups. class _ProteinCard extends StatelessWidget { const _ProteinCard({required this.state}); final NutritionState state; @override Widget build(BuildContext context) { final progress = state.proteinProgress.clamp(0.0, 1.0).toDouble(); final reachedGoal = state.totalProteinGrams >= state.proteinTargetGrams; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Flexible keeps the title from overflowing narrow phones // (and wide-glyph test fonts) when the goal chip is present. Flexible( child: Text( 'Daily Protein Target', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), ), if (reachedGoal) Chip( label: const Text('Goal met'), visualDensity: VisualDensity.compact, backgroundColor: Theme.of(context).colorScheme.secondaryContainer, ), ], ), const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(8), child: LinearProgressIndicator( value: progress, minHeight: 12, color: reachedGoal ? Colors.green : Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, ), ), const SizedBox(height: 8), Text( '${state.totalProteinGrams.toStringAsFixed(1)}g / ' '${state.proteinTargetGrams.toStringAsFixed(0)}g goal', style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 4), Text( _macroSummary(state), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: Theme.of(context).colorScheme.outline), ), ], ), ), ); } /// `1,240 kcal · Carbs 40g · Fat 20g` (macro segments only when logged). String _macroSummary(NutritionState state) { final parts = ['${state.totalCalories} kcal']; final carbs = state.totalCarbsGrams; final fat = state.totalFatGrams; if (carbs != null) parts.add('Carbs ${carbs.toStringAsFixed(0)}g'); if (fat != null) parts.add('Fat ${fat.toStringAsFixed(0)}g'); return parts.join(' · '); } } /// Interactive hydration meter with quick-add buttons (+8 / +16 / +24 oz) /// and today's fluid log (each entry deletable). class _HydrationCard extends ConsumerWidget { const _HydrationCard({required this.state}); final NutritionState state; @override Widget build(BuildContext context, WidgetRef ref) { final progress = state.hydrationProgress.clamp(0.0, 1.0).toDouble(); final reachedGoal = state.totalWaterOz >= state.hydrationTargetOz; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Daily Hydration', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(8), child: LinearProgressIndicator( value: progress, minHeight: 12, color: reachedGoal ? Colors.teal : Colors.blue, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, ), ), const SizedBox(height: 8), Text( '${state.totalWaterOz.toStringAsFixed(0)} oz / ' '${state.hydrationTargetOz.toStringAsFixed(0)} oz goal', style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final increment in [8.0, 16.0, 24.0]) FilledButton.tonalIcon( onPressed: () => _quickAdd(ref, increment), icon: const Icon(Icons.water_drop, size: 16), label: Text('+$increment oz'), ), ], ), if (state.hydrationLogs.isNotEmpty) ...[ const SizedBox(height: 8), for (final entry in state.hydrationLogs) _HydrationTile( entry: entry, onDelete: () => _confirmDelete(context, ref, entry), ), ], ], ), ), ); } Future _quickAdd(WidgetRef ref, double ounces) => ref.read(nutritionControllerProvider.notifier).logWater(ounces); Future _confirmDelete( BuildContext context, WidgetRef ref, HydrationLogEntry entry, ) async { final id = entry.id; if (id == null) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete water entry?'), content: Text( '${entry.volumeOz.toStringAsFixed(0)} oz · ' '${formatDateTime(entry.timestamp)}', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; await ref.read(nutritionControllerProvider.notifier).deleteWater(id); } } /// Single fluid-log row: volume, time, and a delete action. class _HydrationTile extends StatelessWidget { const _HydrationTile({required this.entry, required this.onDelete}); final HydrationLogEntry entry; final VoidCallback onDelete; @override Widget build(BuildContext context) { return ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.water_drop, color: Colors.blue), title: Text('${entry.volumeOz.toStringAsFixed(0)} oz'), subtitle: Text(formatDateTime(entry.timestamp)), trailing: entry.id == null ? null : IconButton( icon: const Icon(Icons.delete_outline), tooltip: 'Delete', onPressed: onDelete, ), ); } } /// Weight tracker card: latest weight, 7/30-day trends, and the /// "Log Weight / Sync" action. /// /// On Android/iOS the button triggers the native [IHealthGateway] sync /// (HealthKit / Health Connect); on Windows/macOS/Web it opens the manual /// entry dialog instead, keeping feature parity without unsupported /// hardware calls. class _WeightTrackerCard extends ConsumerWidget { const _WeightTrackerCard(); @override Widget build(BuildContext context, WidgetRef ref) { final biometric = ref.watch(biometricControllerProvider); final gateway = ref.watch(healthGatewayProvider); final supported = gateway.supportsNativeHealth || gateway.supportsCloudSync; // Surface sync/manual outcomes as snackbars whenever the status changes. ref.listen(biometricControllerProvider, (previous, next) { final message = next.valueOrNull?.statusMessage; if (message == null || message == previous?.valueOrNull?.statusMessage) { return; } ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); }); return Card( child: Padding( padding: const EdgeInsets.all(16), child: biometric.when( loading: () => const SizedBox( height: 120, child: Center(child: CircularProgressIndicator()), ), error: (error, _) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _cardTitle(context), const SizedBox(height: 8), Text('Could not load weight history: $error'), const SizedBox(height: 8), FilledButton( onPressed: () => ref.read(biometricControllerProvider.notifier).retry(), child: const Text('Retry'), ), ], ), data: (state) => _content(context, ref, state, supported), ), ), ); } Widget _content( BuildContext context, WidgetRef ref, BiometricState state, bool supported, ) { final latest = state.latestWeightLbs; final colorScheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _cardTitle(context), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( latest == null ? '-- lb' : '${latest.toStringAsFixed(1)} lb', style: Theme.of(context) .textTheme .headlineSmall ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(width: 8), if (state.latest?.timestamp != null) Text( 'as of ${formatDateTime(state.latest!.timestamp)}', style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: colorScheme.outline), ), ], ), const SizedBox(height: 8), Wrap( spacing: 16, runSpacing: 4, children: [ _trendChip(context, '7-day trend', state.weight7DayDelta), _trendChip(context, '30-day trend', state.weight30DayDelta), if (state.latestActiveCaloriesBurned != null) Text( 'Active burn: ' '${state.latestActiveCaloriesBurned!.toStringAsFixed(0)} kcal', style: Theme.of(context).textTheme.bodyMedium, ), ], ), const SizedBox(height: 12), // Wrap (instead of Row) so the action buttons stack cleanly on very // narrow phones instead of overflowing the card. Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton.icon( onPressed: state.isSyncing ? null : () => _logWeightOrSync(context, ref), icon: state.isSyncing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( supported ? Icons.sync : Icons.monitor_weight_outlined, size: 18, ), label: Text(state.isSyncing ? 'Syncing...' : 'Log Weight / Sync'), ), OutlinedButton.icon( onPressed: () => showLogWeightDialog(context), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Manual'), ), ], ), ], ); } Widget _cardTitle(BuildContext context) => Text( 'Weight Tracker', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ); /// `7-day trend: ▼ 1.2 lb` — green for loss, orange for gain, neutral when /// the history does not reach back far enough. Widget _trendChip(BuildContext context, String label, double? delta) { final color = delta == null ? Theme.of(context).colorScheme.outline : delta < 0 ? Colors.green : delta > 0 ? Colors.orange : Theme.of(context).colorScheme.outline; final text = delta == null ? '—' : '${delta < 0 ? '▼' : delta > 0 ? '▲' : '·'} ' '${delta.abs().toStringAsFixed(1)} lb'; return Text.rich( TextSpan( children: [ TextSpan(text: '$label: '), TextSpan( text: text, style: TextStyle(color: color, fontWeight: FontWeight.bold), ), ], ), style: Theme.of(context).textTheme.bodyMedium, ); } /// Syncs from the first available source — HealthKit / Health Connect on /// mobile, a connected cloud partner (Google Health) on desktop/web — and /// surfaces the sync outcome as a snackbar. Falls back to the manual entry /// dialog when no source is available or connected (the dedicated /// "Manual" button always opens it regardless). Future _logWeightOrSync(BuildContext context, WidgetRef ref) async { final gateway = ref.read(healthGatewayProvider); final canSync = gateway.supportsNativeHealth || await gateway.isCloudSyncReady(); if (!context.mounted) return; if (!canSync) { await showLogWeightDialog(context); return; } await ref.read(biometricControllerProvider.notifier).syncFromHealth(); if (!context.mounted) return; final message = ref.read(biometricControllerProvider).valueOrNull?.statusMessage; if (message != null) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); } } } /// Today's food diary grouped by meal slot (breakfast → snack) with /// per-entry delete actions. class _FoodDiary extends ConsumerWidget { const _FoodDiary({required this.state}); final NutritionState state; @override Widget build(BuildContext context, WidgetRef ref) { final grouped = state.mealsByType; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Food Diary', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), if (grouped.isEmpty) const Card( child: Padding( padding: EdgeInsets.all(16), child: Text( 'No meals logged today yet. Tap "Log Food" to add your ' 'first entry.', ), ), ) else for (final meal in grouped.entries) ...[ Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Builder( builder: (context) { final mealProtein = meal.value.fold( 0, (sum, entry) => sum + entry.proteinGrams, ); return Row( children: [ Icon(_mealIcon(meal.key), size: 18), const SizedBox(width: 8), Text( meal.key.displayName, style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(width: 8), Text( '${mealProtein.toStringAsFixed(0)}g protein', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.outline, ), ), ], ); }, ), ), for (final entry in meal.value) _FoodTile(entry: entry, mealType: meal.key), ], ], ); } IconData _mealIcon(MealType type) { switch (type) { case MealType.breakfast: return Icons.free_breakfast; case MealType.lunch: return Icons.lunch_dining; case MealType.dinner: return Icons.dinner_dining; case MealType.snack: return Icons.cookie; } } } /// Single diary row: description, timestamp, macro summary, delete action. class _FoodTile extends ConsumerWidget { const _FoodTile({required this.entry, required this.mealType}); final FoodLogEntry entry; final MealType mealType; @override Widget build(BuildContext context, WidgetRef ref) { return ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text(entry.description), subtitle: Text(_subtitle()), trailing: entry.id == null ? null : PopupMenuButton( onSelected: (value) { if (value == 'delete') _confirmDelete(context, ref); }, itemBuilder: (context) => const [ PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), ); } /// `Oct 16, 2026 · 8:05 AM · 30g protein · 320 kcal · ...` String _subtitle() { final parts = [ formatDateTime(entry.timestamp), '${entry.proteinGrams.toStringAsFixed(1)}g protein', '${entry.calories} kcal', ]; final carbs = entry.carbsGrams; final fat = entry.fatGrams; if (carbs != null) parts.add('C ${carbs.toStringAsFixed(0)}g'); if (fat != null) parts.add('F ${fat.toStringAsFixed(0)}g'); return parts.join(' · '); } Future _confirmDelete(BuildContext context, WidgetRef ref) async { final id = entry.id; if (id == null) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete meal entry?'), content: Text('${entry.description} · ${mealType.displayName}'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; await ref.read(nutritionControllerProvider.notifier).deleteFood(id); } } /// Full-screen failure state shown when today's logs could not be loaded. class _ErrorView extends StatelessWidget { const _ErrorView({required this.error, required this.onRetry}); final Object error; final VoidCallback onRetry; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: Colors.red), const SizedBox(height: 12), Text( 'Could not load your nutrition data.', style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 4), Text('$error', textAlign: TextAlign.center), const SizedBox(height: 16), FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ), ); } }