Files
recipe-app/flutter/lib/features/meal_plan/presentation/meal_plan_screen.dart
T
Nils-Johan Gynther e495a4b38e feat: add meal planning feature with API integration
- Introduced MealPlanApiPaths for handling meal plan related API endpoints.
- Added MealPlanScreen for displaying and managing meal plans.
- Implemented MealPlanRepository for fetching and updating meal plan data.
- Created data models: MealPlanEntry, MealPlanRecipe, InventoryCompareItem, ShoppingItem, and MealPlanDashboard.
- Integrated meal plan functionality into the app router and UI.
- Updated localization files for meal plan related strings in English and Swedish.
- Added state management for meal plan using Riverpod.
2026-04-22 19:51:33 +02:00

593 lines
19 KiB
Dart

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/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<MealPlanScreen> createState() => _MealPlanScreenState();
}
class _MealPlanScreenState extends ConsumerState<MealPlanScreen> {
String? _savingDate;
Future<void> _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.valueOrNull ?? const <Recipe>[];
final dashboard = dashboardAsync.valueOrNull ??
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).state--,
icon: const Icon(Icons.chevron_left),
label: Text(l10n.mealPlanWeekPrevious),
),
Chip(label: Text(weekLabel)),
OutlinedButton.icon(
onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).state++,
icon: const Icon(Icons.chevron_right),
label: Text(l10n.mealPlanWeekNext),
),
if (ref.watch(mealPlanWeekOffsetProvider) != 0)
TextButton(
onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).state = 0,
child: Text(l10n.mealPlanWeekCurrent),
),
],
),
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<Recipe> 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<String>(
value: selectedValue,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.mealPlanSelectRecipe,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String>(
value: '',
child: Text(l10n.mealPlanDayNoRecipe),
),
...recipes
.map(
(recipe) => DropdownMenuItem<String>(
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<int>(
value: currentServings,
decoration: InputDecoration(
labelText: l10n.mealPlanServingsLabel,
border: const OutlineInputBorder(),
),
items: _servingsOptions(currentServings, recipeServings)
.map(
(servings) => DropdownMenuItem<int>(
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<int> _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<Widget> _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<InventoryCompareItem> 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<InventoryCompareItem> compareItems,
) {
final compareItem = compareItems
.where((compare) => compare.productId == item.productId && compare.unit == item.unit)
.cast<InventoryCompareItem?>()
.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,
_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;
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) {
final normalized = value == value.roundToDouble() ? value.toStringAsFixed(0) : value.toStringAsFixed(1);
return normalized.replaceAll(RegExp(r'\.0$'), '');
}
}