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:
@@ -27,3 +27,19 @@ 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)}';
|
||||
}
|
||||
@@ -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<GoRouter>((ref) {
|
||||
@@ -159,6 +160,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/inventory',
|
||||
builder: (context, state) => const InventoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/matsedel',
|
||||
builder: (context, state) => const MealPlanScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/baslager',
|
||||
builder: (context, state) => const PantryScreen(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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$'), '');
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user