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 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)}';
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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$'), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user