import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../../recipes/data/recipe_providers.dart'; import '../../recipes/domain/recipe.dart'; import '../data/meal_plan_providers.dart'; import '../domain/inventory_compare_item.dart'; import '../domain/meal_plan_dashboard.dart'; import '../domain/meal_plan_entry.dart'; import '../domain/meal_plan_week.dart'; import '../domain/shopping_item.dart'; class MealPlanScreen extends ConsumerStatefulWidget { const MealPlanScreen({super.key}); @override ConsumerState createState() => _MealPlanScreenState(); } class _MealPlanScreenState extends ConsumerState { String? _savingDate; Future _saveSelection({ required String date, required int? recipeId, int? servings, }) async { if (_savingDate != null) return; setState(() => _savingDate = date); try { final token = await ref.read(authStateProvider.future); final repository = ref.read(mealPlanRepositoryProvider); if (recipeId == null) { await repository.deleteByDate(date, token: token); } else { await repository.upsert( date: date, recipeId: recipeId, servings: servings, token: token, ); } ref.invalidate(mealPlanDashboardProvider); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(mapErrorToUserMessage(error, context))), ); } finally { if (mounted) { setState(() => _savingDate = null); } } } @override Widget build(BuildContext context) { final l10n = context.l10n; final locale = Localizations.localeOf(context).toLanguageTag(); final recipesAsync = ref.watch(recipesProvider); final dashboardAsync = ref.watch(mealPlanDashboardProvider); final week = ref.watch(mealPlanWeekProvider); if (recipesAsync.isLoading || dashboardAsync.isLoading) { return LoadingStateView(label: l10n.mealPlanLoading); } if (recipesAsync.hasError || dashboardAsync.hasError) { final error = recipesAsync.error ?? dashboardAsync.error; return ErrorStateView( message: mapErrorToUserMessage(error ?? l10n.unexpectedError, context), onRetry: () { ref.invalidate(recipesProvider); ref.invalidate(mealPlanDashboardProvider); }, ); } final recipes = recipesAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; final dashboard = dashboardAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const MealPlanDashboard( entries: [], shoppingItems: [], inventoryCompareItems: [], ); if (recipes.isEmpty) { return EmptyStateView( title: l10n.mealPlanNoRecipesTitle, description: l10n.mealPlanNoRecipesDescription, ); } final plannedCount = week.days.where((day) => dashboard.entryForDate(day) != null).length; final weekLabel = _formatWeekLabel(week, locale); return ListView( padding: const EdgeInsets.all(12), children: [ Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton.icon( onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).decrement(), icon: const Icon(Icons.chevron_left), label: Text(l10n.mealPlanWeekPrevious), ), Chip(label: Text(weekLabel)), OutlinedButton.icon( onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).increment(), icon: const Icon(Icons.chevron_right), label: Text(l10n.mealPlanWeekNext), ), if (ref.watch(mealPlanWeekOffsetProvider) != 0) TextButton( onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).reset(), child: Text(l10n.mealPlanWeekCurrent), ), IconButton( tooltip: 'Gå till recept', icon: const Icon(Icons.restaurant_menu), onPressed: () => context.go('/recipes'), ), ], ), const SizedBox(height: 12), ...week.days.map( (day) => _DayCard( date: day, locale: locale, entry: dashboard.entryForDate(day), recipes: recipes, isSaving: _savingDate == week.isoDate(day), onSelected: (recipeId, servings) => _saveSelection( date: week.isoDate(day), recipeId: recipeId, servings: servings, ), ), ), const SizedBox(height: 16), _ShoppingSection( dashboard: dashboard, plannedCount: plannedCount, ), ], ); } String _formatWeekLabel(MealPlanWeek week, String locale) { final from = DateFormat('d MMM', locale).format(week.start); final to = DateFormat('d MMM y', locale).format(week.end); return '$from - $to'; } } class _DayCard extends StatelessWidget { final DateTime date; final String locale; final MealPlanEntry? entry; final List recipes; final bool isSaving; final void Function(int? recipeId, int? servings) onSelected; const _DayCard({ required this.date, required this.locale, required this.entry, required this.recipes, required this.isSaving, required this.onSelected, }); @override Widget build(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); final today = DateTime.now(); final isToday = today.year == date.year && today.month == date.month && today.day == date.day; final selectedValue = entry?.recipe.id.toString() ?? ''; final recipeServings = entry?.recipe.servings; final currentServings = entry?.servings ?? recipeServings; return Card( margin: const EdgeInsets.only(bottom: 10), color: isToday ? theme.colorScheme.primaryContainer.withValues(alpha: 0.45) : null, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _formatDayLabel(), style: theme.textTheme.titleMedium, ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedValue, isExpanded: true, decoration: InputDecoration( labelText: l10n.mealPlanSelectRecipe, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem( value: '', child: Text(l10n.mealPlanDayNoRecipe), ), ...recipes .map( (recipe) => DropdownMenuItem( value: recipe.id.toString(), child: Text(recipe.title), ), ) .toList(), ], onChanged: isSaving ? null : (value) { onSelected( int.tryParse((value ?? '').trim()), entry?.servings, ); }, ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ if (entry != null) TextButton.icon( onPressed: isSaving ? null : () => context.push('/recipes/${entry!.recipe.id}'), icon: const Icon(Icons.open_in_new), label: Text(l10n.mealPlanViewRecipe), ), if (recipeServings != null && entry != null) SizedBox( width: 220, child: DropdownButtonFormField( initialValue: currentServings, decoration: InputDecoration( labelText: l10n.mealPlanServingsLabel, border: const OutlineInputBorder(), ), items: _servingsOptions(currentServings, recipeServings) .map( (servings) => DropdownMenuItem( value: servings, child: Text(servings.toString()), ), ) .toList(), onChanged: isSaving ? null : (value) { if (value == null || entry == null) return; onSelected(entry!.recipe.id, value); }, ), ), if (entry != null && entry!.servings != null && recipeServings != null) OutlinedButton( onPressed: isSaving ? null : () => onSelected(entry!.recipe.id, null), child: Text('${l10n.mealPlanResetServings} (${recipeServings.toString()})'), ), if (isSaving) Text( l10n.mealPlanSaving, style: theme.textTheme.bodySmall, ), ], ), ], ), ), ); } String _formatDayLabel() { final weekday = DateFormat('EEEE', locale).format(date); final monthDay = DateFormat('d MMM', locale).format(date); return '${_capitalize(weekday)} - $monthDay'; } List _servingsOptions(int? currentServings, int recipeServings) { final upperBound = math.max(12, math.max(currentServings ?? recipeServings, recipeServings)); return List.generate(upperBound, (index) => index + 1); } String _capitalize(String value) { if (value.isEmpty) return value; return '${value[0].toUpperCase()}${value.substring(1)}'; } } class _ShoppingSection extends StatelessWidget { final MealPlanDashboard dashboard; final int plannedCount; const _ShoppingSection({ required this.dashboard, required this.plannedCount, }); @override Widget build(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.mealPlanShoppingTitle, style: theme.textTheme.titleMedium), const SizedBox(height: 4), Text( l10n.mealPlanPlannedRecipes(plannedCount), style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), if (plannedCount == 0) Text(l10n.mealPlanPickRecipeHint) else ...[ _ShoppingSummary(compareItems: dashboard.inventoryCompareItems), const SizedBox(height: 8), if (dashboard.shoppingItems.isEmpty) Text(l10n.mealPlanNoShoppingItems) else ..._buildRows(context), ], ], ), ), ); } List _buildRows(BuildContext context) { final items = dashboard.shoppingItems .map((item) => _EnrichedShoppingItem.from(item, dashboard.inventoryCompareItems)) .toList() ..sort((a, b) { final statusDiff = a.sortOrder.compareTo(b.sortOrder); if (statusDiff != 0) return statusDiff; return a.item.name.toLowerCase().compareTo(b.item.name.toLowerCase()); }); return items .map( (item) => Padding( padding: const EdgeInsets.only(bottom: 8), child: _ShoppingRow(item: item), ), ) .toList(); } } class _ShoppingSummary extends StatelessWidget { final List compareItems; const _ShoppingSummary({required this.compareItems}); @override Widget build(BuildContext context) { if (compareItems.isEmpty) { return const SizedBox.shrink(); } final l10n = context.l10n; final missing = compareItems.where((item) => item.status == 'missing').length; final enough = compareItems.where((item) => item.status == 'enough').length; final pantry = compareItems.where((item) => item.status == 'pantry').length; final partial = compareItems .where((item) => item.status == 'missing' && item.availableQuantity > 0) .length; final adjustedMissing = missing - partial; return Wrap( spacing: 8, runSpacing: 8, children: [ if (adjustedMissing > 0) Chip( avatar: const Icon(Icons.error_outline, size: 18), label: Text(l10n.mealPlanMissingCount(adjustedMissing)), ), if (partial > 0) Chip( avatar: const Icon(Icons.warning_amber_rounded, size: 18), label: Text(l10n.mealPlanPartialCount(partial)), ), if (enough > 0) Chip( avatar: const Icon(Icons.check_circle_outline, size: 18), label: Text(l10n.mealPlanEnoughCount(enough)), ), if (pantry > 0) Chip( avatar: const Icon(Icons.inventory_2_outlined, size: 18), label: Text(l10n.mealPlanPantryCount(pantry)), ), if (adjustedMissing <= 0 && partial <= 0) Chip( avatar: const Icon(Icons.check_circle_outline, size: 18), label: Text(l10n.mealPlanAllAtHome), ), ], ); } } class _ShoppingRow extends StatelessWidget { final _EnrichedShoppingItem item; const _ShoppingRow({required this.item}); @override Widget build(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); final colorScheme = theme.colorScheme; final (icon, label, background) = switch (item.status) { _DisplayStatus.partial => ( Icons.warning_amber_rounded, l10n.mealPlanStatusPartial, colorScheme.tertiaryContainer, ), _DisplayStatus.enough => ( Icons.check_circle_outline, l10n.mealPlanStatusEnough, colorScheme.secondaryContainer, ), _DisplayStatus.pantry => ( Icons.inventory_2_outlined, l10n.mealPlanStatusPantry, colorScheme.surfaceContainerHighest, ), _DisplayStatus.missing => ( Icons.error_outline, l10n.mealPlanStatusMissing, colorScheme.errorContainer, ), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(icon, size: 18), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(item.item.name, style: theme.textTheme.bodyLarge), const SizedBox(height: 2), Text( item.subtitle(label), style: theme.textTheme.bodySmall, ), ], ), ), const SizedBox(width: 8), Text( item.trailingText, style: theme.textTheme.labelLarge, ), ], ), ); } } enum _DisplayStatus { missing, partial, enough, pantry } class _EnrichedShoppingItem { final ShoppingItem item; final InventoryCompareItem? compareItem; final _DisplayStatus status; const _EnrichedShoppingItem({ required this.item, required this.compareItem, required this.status, }); factory _EnrichedShoppingItem.from( ShoppingItem item, List compareItems, ) { final compareItem = compareItems .where((compare) => compare.productId == item.productId && compare.unit == item.unit) .cast() .firstWhere((compare) => compare != null, orElse: () => null); if (compareItem == null) { return _EnrichedShoppingItem( item: item, compareItem: null, status: _DisplayStatus.missing, ); } if (compareItem.status == 'pantry') { return _EnrichedShoppingItem( item: item, compareItem: compareItem, status: _DisplayStatus.pantry, ); } if (compareItem.availableQuantity >= compareItem.requiredQuantity) { return _EnrichedShoppingItem( item: item, compareItem: compareItem, status: _DisplayStatus.enough, ); } if (compareItem.availableQuantity > 0) { return _EnrichedShoppingItem( item: item, compareItem: compareItem, status: _DisplayStatus.partial, ); } return _EnrichedShoppingItem( item: item, compareItem: compareItem, status: _DisplayStatus.missing, ); } int get sortOrder => switch (status) { _DisplayStatus.missing => 0, _DisplayStatus.partial => 1, _DisplayStatus.enough => 2, _DisplayStatus.pantry => 3, }; String get trailingText { final quantity = switch (status) { _DisplayStatus.pantry || _DisplayStatus.enough => 0.0, _DisplayStatus.partial => compareItem?.missingQuantity ?? item.quantity, _DisplayStatus.missing => item.quantity, }; if (quantity <= 0) { return '-'; } return '${_formatQuantity(quantity)} ${item.unit}'; } String subtitle(String label) { switch (status) { case _DisplayStatus.partial: final available = compareItem?.availableQuantity ?? 0.0; final required = compareItem?.requiredQuantity ?? item.quantity; return '$label • ${_formatQuantity(available)} av ${_formatQuantity(required)} ${item.unit} hemma'; case _DisplayStatus.enough: case _DisplayStatus.pantry: return label; case _DisplayStatus.missing: return '$label • ${_formatQuantity(item.quantity)} ${item.unit} behövs'; } } String _formatQuantity(double value) => formatQuantity(value); }