glp-companion/lib/features/nutrition/presentation/screens/nutrition_screen.dart

681 lines
22 KiB
Dart

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 = <String>['${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<void> _quickAdd(WidgetRef ref, double ounces) =>
ref.read(nutritionControllerProvider.notifier).logWater(ounces);
Future<void> _confirmDelete(
BuildContext context,
WidgetRef ref,
HydrationLogEntry entry,
) async {
final id = entry.id;
if (id == null) return;
final confirmed = await showDialog<bool>(
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<void> _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<double>(
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<String>(
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 = <String>[
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<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final id = entry.id;
if (id == null) return;
final confirmed = await showDialog<bool>(
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'),
),
],
),
),
);
}
}