diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 1b9cc612..fc3217e4 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -26,4 +26,20 @@ class InventoryApiPaths { class PantryApiPaths { static const list = '/pantry'; static String remove(int id) => '/pantry/$id'; +} + +class MealPlanApiPaths { + static const list = '/meal-plan'; + + static String listByRange(String from, String to) => + '$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + + static String shoppingList(String from, String to) => + '$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + + static String inventoryCompare(String from, String to) => + '$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + + static String removeByDate(String date) => + '$list/${Uri.encodeComponent(date)}'; } \ No newline at end of file diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 755d5556..e7f23e6e 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ import '../../features/inventory/presentation/create_inventory_screen.dart'; import '../../features/inventory/presentation/inventory_edit_screen.dart'; import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; +import '../../features/meal_plan/presentation/meal_plan_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart'; final appRouterProvider = Provider((ref) { @@ -159,6 +160,10 @@ final appRouterProvider = Provider((ref) { path: '/inventory', builder: (context, state) => const InventoryScreen(), ), + GoRoute( + path: '/matsedel', + builder: (context, state) => const MealPlanScreen(), + ), GoRoute( path: '/baslager', builder: (context, state) => const PantryScreen(), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index c002a926..79900ee6 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -27,6 +27,12 @@ class AppShell extends ConsumerWidget { icon: Icons.inventory_2_outlined, label: 'Inventarie', ), + _AppDestination( + path: '/matsedel', + title: 'Matsedel', + icon: Icons.calendar_month_outlined, + label: 'Matsedel', + ), _AppDestination( path: '/baslager', title: 'Baslager', diff --git a/flutter/lib/features/meal_plan/data/meal_plan_providers.dart b/flutter/lib/features/meal_plan/data/meal_plan_providers.dart new file mode 100644 index 00000000..bec31c1a --- /dev/null +++ b/flutter/lib/features/meal_plan/data/meal_plan_providers.dart @@ -0,0 +1,41 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; +import '../../auth/data/auth_providers.dart'; +import '../domain/meal_plan_dashboard.dart'; +import '../domain/meal_plan_week.dart'; +import 'meal_plan_repository.dart'; + +final mealPlanRepositoryProvider = Provider((ref) { + return MealPlanRepository(ref.watch(apiClientProvider)); +}); + +final mealPlanWeekOffsetProvider = StateProvider((ref) => 0); + +final mealPlanWeekProvider = Provider((ref) { + final offset = ref.watch(mealPlanWeekOffsetProvider); + return MealPlanWeek.fromOffset(offset); +}); + +final mealPlanDashboardProvider = FutureProvider((ref) async { + final week = ref.watch(mealPlanWeekProvider); + final token = await ref.watch(authStateProvider.future); + + return guardedApiCall(ref, () async { + final repository = ref.read(mealPlanRepositoryProvider); + final entries = await repository.fetchEntries(week.fromIso, week.toIso, token: token); + final shoppingItems = await repository.fetchShoppingList(week.fromIso, week.toIso, token: token); + final inventoryCompareItems = await repository.fetchInventoryCompare( + week.fromIso, + week.toIso, + token: token, + ); + + return MealPlanDashboard( + entries: entries, + shoppingItems: shoppingItems, + inventoryCompareItems: inventoryCompareItems, + ); + }); +}); \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/data/meal_plan_repository.dart b/flutter/lib/features/meal_plan/data/meal_plan_repository.dart new file mode 100644 index 00000000..edf1f0aa --- /dev/null +++ b/flutter/lib/features/meal_plan/data/meal_plan_repository.dart @@ -0,0 +1,124 @@ +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; +import '../domain/inventory_compare_item.dart'; +import '../domain/meal_plan_entry.dart'; +import '../domain/shopping_item.dart'; + +class MealPlanRepository { + final ApiClient _api; + + const MealPlanRepository(this._api); + + Future> fetchEntries(String from, String to, {String? token}) async { + try { + final data = await _api.getJson(MealPlanApiPaths.listByRange(from, to), token: token); + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return data + .map((item) => MealPlanEntry.fromJson(item as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta matsedeln.', + ); + } + } + + Future> fetchShoppingList(String from, String to, {String? token}) async { + try { + final data = await _api.getJson(MealPlanApiPaths.shoppingList(from, to), token: token); + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return data + .map((item) => ShoppingItem.fromJson(item as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta inköpslistan.', + ); + } + } + + Future> fetchInventoryCompare(String from, String to, {String? token}) async { + try { + final data = await _api.getJson(MealPlanApiPaths.inventoryCompare(from, to), token: token); + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return data + .map((item) => InventoryCompareItem.fromJson(item as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta lagerjämförelsen.', + ); + } + } + + Future upsert({ + required String date, + required int recipeId, + int? servings, + String? token, + }) async { + try { + final data = await _api.postJson( + MealPlanApiPaths.list, + body: { + 'date': date, + 'recipeId': recipeId, + 'servings': servings, + }, + token: token, + ); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return MealPlanEntry.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte spara matsedeln.', + ); + } + } + + Future deleteByDate(String date, {String? token}) async { + try { + await _api.deleteJson(MealPlanApiPaths.removeByDate(date), token: token); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte ta bort dagens planering.', + ); + } + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/inventory_compare_item.dart b/flutter/lib/features/meal_plan/domain/inventory_compare_item.dart new file mode 100644 index 00000000..ef06caf7 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/inventory_compare_item.dart @@ -0,0 +1,42 @@ +class InventoryCompareItem { + final int productId; + final String name; + final double requiredQuantity; + final String unit; + final double availableQuantity; + final double missingQuantity; + final String status; + + const InventoryCompareItem({ + required this.productId, + required this.name, + required this.requiredQuantity, + required this.unit, + required this.availableQuantity, + required this.missingQuantity, + required this.status, + }); + + factory InventoryCompareItem.fromJson(Map json) { + final rawId = json['productId']; + final rawRequired = json['required']; + final rawAvailable = json['available']; + final rawMissing = json['missing']; + + return InventoryCompareItem( + productId: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), + name: json['name'].toString(), + requiredQuantity: rawRequired is num + ? rawRequired.toDouble() + : double.parse(rawRequired.toString()), + unit: json['unit'].toString(), + availableQuantity: rawAvailable is num + ? rawAvailable.toDouble() + : double.parse(rawAvailable.toString()), + missingQuantity: rawMissing is num + ? rawMissing.toDouble() + : double.parse(rawMissing.toString()), + status: json['status'].toString(), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/meal_plan_dashboard.dart b/flutter/lib/features/meal_plan/domain/meal_plan_dashboard.dart new file mode 100644 index 00000000..aefeb8e1 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/meal_plan_dashboard.dart @@ -0,0 +1,26 @@ +import 'inventory_compare_item.dart'; +import 'meal_plan_entry.dart'; +import 'shopping_item.dart'; + +class MealPlanDashboard { + final List entries; + final List shoppingItems; + final List inventoryCompareItems; + + const MealPlanDashboard({ + required this.entries, + required this.shoppingItems, + required this.inventoryCompareItems, + }); + + MealPlanEntry? entryForDate(DateTime date) { + final normalized = DateTime(date.year, date.month, date.day); + for (final entry in entries) { + final entryDate = DateTime(entry.date.year, entry.date.month, entry.date.day); + if (entryDate == normalized) { + return entry; + } + } + return null; + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/meal_plan_entry.dart b/flutter/lib/features/meal_plan/domain/meal_plan_entry.dart new file mode 100644 index 00000000..c9fcfbc1 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/meal_plan_entry.dart @@ -0,0 +1,31 @@ +import 'meal_plan_recipe.dart'; + +class MealPlanEntry { + final int id; + final DateTime date; + final int? servings; + final MealPlanRecipe recipe; + + const MealPlanEntry({ + required this.id, + required this.date, + required this.recipe, + this.servings, + }); + + factory MealPlanEntry.fromJson(Map json) { + final rawId = json['id']; + final rawServings = json['servings']; + + return MealPlanEntry( + id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), + date: DateTime.parse(json['date'].toString()), + servings: rawServings == null + ? null + : rawServings is num + ? rawServings.toInt() + : int.tryParse(rawServings.toString()), + recipe: MealPlanRecipe.fromJson(json['recipe'] as Map), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/meal_plan_recipe.dart b/flutter/lib/features/meal_plan/domain/meal_plan_recipe.dart new file mode 100644 index 00000000..75e497a7 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/meal_plan_recipe.dart @@ -0,0 +1,31 @@ +class MealPlanRecipe { + final int id; + final String title; + final String? imageUrl; + final int? servings; + + const MealPlanRecipe({ + required this.id, + required this.title, + this.imageUrl, + this.servings, + }); + + factory MealPlanRecipe.fromJson(Map json) { + final rawId = json['id']; + final rawTitle = json['title'] ?? json['name']; + final rawServings = json['servings']; + final rawImageUrl = json['imageUrl']?.toString().trim(); + + return MealPlanRecipe( + id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), + title: (rawTitle ?? '').toString(), + imageUrl: rawImageUrl == null || rawImageUrl.isEmpty ? null : rawImageUrl, + servings: rawServings == null + ? null + : rawServings is num + ? rawServings.toInt() + : int.tryParse(rawServings.toString()), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/meal_plan_week.dart b/flutter/lib/features/meal_plan/domain/meal_plan_week.dart new file mode 100644 index 00000000..a858ae31 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/meal_plan_week.dart @@ -0,0 +1,34 @@ +import 'package:intl/intl.dart'; + +class MealPlanWeek { + final DateTime start; + final List days; + + const MealPlanWeek({ + required this.start, + required this.days, + }); + + factory MealPlanWeek.fromOffset(int offset) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final monday = today.subtract(Duration(days: today.weekday - 1)).add( + Duration(days: offset * 7), + ); + + return MealPlanWeek( + start: monday, + days: List.generate( + 7, + (index) => DateTime(monday.year, monday.month, monday.day + index), + ), + ); + } + + DateTime get end => days.last; + + String get fromIso => isoDate(start); + String get toIso => isoDate(end); + + String isoDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date); +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/domain/shopping_item.dart b/flutter/lib/features/meal_plan/domain/shopping_item.dart new file mode 100644 index 00000000..0c8944f4 --- /dev/null +++ b/flutter/lib/features/meal_plan/domain/shopping_item.dart @@ -0,0 +1,27 @@ +class ShoppingItem { + final int productId; + final String name; + final double quantity; + final String unit; + + const ShoppingItem({ + required this.productId, + required this.name, + required this.quantity, + required this.unit, + }); + + factory ShoppingItem.fromJson(Map json) { + final rawId = json['productId']; + final rawQuantity = json['quantity']; + + return ShoppingItem( + productId: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), + name: json['name'].toString(), + quantity: rawQuantity is num + ? rawQuantity.toDouble() + : double.parse(rawQuantity.toString()), + unit: json['unit'].toString(), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/meal_plan/presentation/meal_plan_screen.dart b/flutter/lib/features/meal_plan/presentation/meal_plan_screen.dart new file mode 100644 index 00000000..28cc42d4 --- /dev/null +++ b/flutter/lib/features/meal_plan/presentation/meal_plan_screen.dart @@ -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 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.valueOrNull ?? const []; + 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 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( + value: 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( + value: 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, + _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$'), ''); + } +} \ No newline at end of file diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb index c2f0267e..62ed9054 100644 --- a/flutter/lib/l10n/app_en.arb +++ b/flutter/lib/l10n/app_en.arb @@ -2,6 +2,67 @@ "@@locale": "en", "appTitle": "Recipe App", "retryAction": "Retry", + "mealPlanTitle": "Meal plan", + "mealPlanLoading": "Loading meal plan...", + "mealPlanWeekPrevious": "Previous week", + "mealPlanWeekNext": "Next week", + "mealPlanWeekCurrent": "Current week", + "mealPlanDayNoRecipe": "Nothing planned", + "mealPlanSelectRecipe": "Choose recipe", + "mealPlanViewRecipe": "View recipe", + "mealPlanServingsLabel": "Servings", + "mealPlanResetServings": "Reset", + "mealPlanSaving": "Saving...", + "mealPlanPlannedRecipes": "{count, plural, one {# recipe planned} other {# recipes planned}}", + "@mealPlanPlannedRecipes": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanShoppingTitle": "Shopping list", + "mealPlanPickRecipeHint": "Choose recipes above to see the combined ingredient list.", + "mealPlanNoShoppingItems": "No ingredients to show for this week.", + "mealPlanNoRecipesTitle": "There are no recipes to plan yet.", + "mealPlanNoRecipesDescription": "Create at least one recipe first, then add it to the meal plan.", + "mealPlanMissingCount": "{count, plural, one {# missing} other {# missing}}", + "@mealPlanMissingCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanPartialCount": "{count, plural, one {# partially at home} other {# partially at home}}", + "@mealPlanPartialCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanEnoughCount": "{count, plural, one {# at home} other {# at home}}", + "@mealPlanEnoughCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanPantryCount": "{count, plural, one {# pantry staple} other {# pantry staples}}", + "@mealPlanPantryCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanAllAtHome": "You already have everything at home.", + "mealPlanStatusMissing": "Missing", + "mealPlanStatusPartial": "Partially at home", + "mealPlanStatusEnough": "At home", + "mealPlanStatusPantry": "Pantry staple", "loginTitle": "Sign in", "usernameLabel": "Username", "usernameRequired": "Enter your username.", diff --git a/flutter/lib/l10n/app_sv.arb b/flutter/lib/l10n/app_sv.arb index 48f6a87e..fdb2c8fb 100644 --- a/flutter/lib/l10n/app_sv.arb +++ b/flutter/lib/l10n/app_sv.arb @@ -2,6 +2,67 @@ "@@locale": "sv", "appTitle": "Recipe App", "retryAction": "Försök igen", + "mealPlanTitle": "Matsedel", + "mealPlanLoading": "Laddar matsedel...", + "mealPlanWeekPrevious": "Förra veckan", + "mealPlanWeekNext": "Nästa vecka", + "mealPlanWeekCurrent": "Denna vecka", + "mealPlanDayNoRecipe": "Inget planerat", + "mealPlanSelectRecipe": "Välj recept", + "mealPlanViewRecipe": "Visa recept", + "mealPlanServingsLabel": "Portioner", + "mealPlanResetServings": "Återställ", + "mealPlanSaving": "Sparar...", + "mealPlanPlannedRecipes": "{count, plural, one {# recept planerat} other {# recept planerade}}", + "@mealPlanPlannedRecipes": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanShoppingTitle": "Inköpslista", + "mealPlanPickRecipeHint": "Välj recept ovan för att se en samlad ingredienslista.", + "mealPlanNoShoppingItems": "Inga ingredienser att visa för den här veckan.", + "mealPlanNoRecipesTitle": "Det finns inga recept att planera ännu.", + "mealPlanNoRecipesDescription": "Skapa minst ett recept först, så kan du lägga det i matsedeln.", + "mealPlanMissingCount": "{count, plural, one {# saknas} other {# saknas}}", + "@mealPlanMissingCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanPartialCount": "{count, plural, one {# delvis hemma} other {# delvis hemma}}", + "@mealPlanPartialCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanEnoughCount": "{count, plural, one {# hemma} other {# hemma}}", + "@mealPlanEnoughCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanPantryCount": "{count, plural, one {# baslager} other {# baslager}}", + "@mealPlanPantryCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mealPlanAllAtHome": "Du har allt hemma.", + "mealPlanStatusMissing": "Saknas", + "mealPlanStatusPartial": "Delvis hemma", + "mealPlanStatusEnough": "Finns hemma", + "mealPlanStatusPantry": "Baslager", "loginTitle": "Logga in", "usernameLabel": "Användarnamn", "usernameRequired": "Ange ditt användarnamn.",