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
@@ -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.',
);
}
}
}