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:
Nils-Johan Gynther
2026-04-22 19:51:33 +02:00
parent b8627d0b7f
commit e495a4b38e
14 changed files with 1098 additions and 0 deletions
+16
View File
@@ -27,3 +27,19 @@ class PantryApiPaths {
static const list = '/pantry'; static const list = '/pantry';
static String remove(int id) => '/pantry/$id'; 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)}';
}
+5
View File
@@ -17,6 +17,7 @@ import '../../features/inventory/presentation/create_inventory_screen.dart';
import '../../features/inventory/presentation/inventory_edit_screen.dart'; import '../../features/inventory/presentation/inventory_edit_screen.dart';
import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consume_inventory_screen.dart';
import '../../features/inventory/presentation/consumption_history_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'; import '../../features/pantry/presentation/pantry_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
@@ -159,6 +160,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/inventory', path: '/inventory',
builder: (context, state) => const InventoryScreen(), builder: (context, state) => const InventoryScreen(),
), ),
GoRoute(
path: '/matsedel',
builder: (context, state) => const MealPlanScreen(),
),
GoRoute( GoRoute(
path: '/baslager', path: '/baslager',
builder: (context, state) => const PantryScreen(), builder: (context, state) => const PantryScreen(),
+6
View File
@@ -27,6 +27,12 @@ class AppShell extends ConsumerWidget {
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
label: 'Inventarie', label: 'Inventarie',
), ),
_AppDestination(
path: '/matsedel',
title: 'Matsedel',
icon: Icons.calendar_month_outlined,
label: 'Matsedel',
),
_AppDestination( _AppDestination(
path: '/baslager', path: '/baslager',
title: 'Baslager', title: 'Baslager',
@@ -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<MealPlanRepository>((ref) {
return MealPlanRepository(ref.watch(apiClientProvider));
});
final mealPlanWeekOffsetProvider = StateProvider<int>((ref) => 0);
final mealPlanWeekProvider = Provider<MealPlanWeek>((ref) {
final offset = ref.watch(mealPlanWeekOffsetProvider);
return MealPlanWeek.fromOffset(offset);
});
final mealPlanDashboardProvider = FutureProvider<MealPlanDashboard>((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,
);
});
});
@@ -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<List<MealPlanEntry>> 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<String, dynamic>))
.toList();
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta matsedeln.',
);
}
}
Future<List<ShoppingItem>> 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<String, dynamic>))
.toList();
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta inköpslistan.',
);
}
}
Future<List<InventoryCompareItem>> 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<String, dynamic>))
.toList();
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta lagerjämförelsen.',
);
}
}
Future<MealPlanEntry> 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<String, dynamic>) {
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<void> 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.',
);
}
}
}
@@ -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<String, dynamic> 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(),
);
}
}
@@ -0,0 +1,26 @@
import 'inventory_compare_item.dart';
import 'meal_plan_entry.dart';
import 'shopping_item.dart';
class MealPlanDashboard {
final List<MealPlanEntry> entries;
final List<ShoppingItem> shoppingItems;
final List<InventoryCompareItem> 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;
}
}
@@ -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<String, dynamic> 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<String, dynamic>),
);
}
}
@@ -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<String, dynamic> 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()),
);
}
}
@@ -0,0 +1,34 @@
import 'package:intl/intl.dart';
class MealPlanWeek {
final DateTime start;
final List<DateTime> 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);
}
@@ -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<String, dynamic> 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(),
);
}
}
@@ -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$'), '');
}
}
+61
View File
@@ -2,6 +2,67 @@
"@@locale": "en", "@@locale": "en",
"appTitle": "Recipe App", "appTitle": "Recipe App",
"retryAction": "Retry", "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", "loginTitle": "Sign in",
"usernameLabel": "Username", "usernameLabel": "Username",
"usernameRequired": "Enter your username.", "usernameRequired": "Enter your username.",
+61
View File
@@ -2,6 +2,67 @@
"@@locale": "sv", "@@locale": "sv",
"appTitle": "Recipe App", "appTitle": "Recipe App",
"retryAction": "Försök igen", "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", "loginTitle": "Logga in",
"usernameLabel": "Användarnamn", "usernameLabel": "Användarnamn",
"usernameRequired": "Ange ditt användarnamn.", "usernameRequired": "Ange ditt användarnamn.",