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.
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
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$'), '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user