Add meal plan feature with cross-device sync and automatic stale data cleanup

Introduces weekly meal planning with a calendar-based tab view, per-recipe
date assignments synced via Nextcloud Cookbook custom metadata, and 30-day
automatic pruning of old entries on load, save, and sync merge.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 05:23:29 +01:00
parent 5890dbcad4
commit 8b23652f10
17 changed files with 1332 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ import SwiftUI
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
@@ -50,6 +51,15 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
.sheet(isPresented: $viewModel.presentMealPlanSheet) {
AddToMealPlanSheet(
recipeId: String(viewModel.recipe.recipe_id),
recipeName: viewModel.observableRecipeDetail.name,
prepTime: viewModel.recipeDetail.prepTime,
recipeImage: viewModel.recipeImage
)
.environmentObject(mealPlan)
}
.task {
// Load recipe detail
if !viewModel.newRecipe {
@@ -85,6 +95,15 @@ struct RecipeView: View {
)
}
// Reconcile server meal plan state with local data
if UserSettings.shared.mealPlanSyncEnabled {
mealPlan.syncManager?.reconcileFromServer(
serverAssignment: viewModel.recipeDetail.mealPlanAssignment,
recipeId: String(viewModel.recipe.recipe_id),
recipeName: viewModel.recipeDetail.name
)
}
} else {
// Prepare view for a new recipe
if let preloaded = viewModel.preloadedRecipeDetail {
@@ -196,6 +215,22 @@ struct RecipeView: View {
RecipeDurationSection(viewModel: viewModel)
Button {
viewModel.presentMealPlanSheet = true
} label: {
Label("Plan recipe", systemImage: "calendar.badge.plus")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nextcloudBlue.opacity(0.1))
)
}
.padding(.horizontal)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
@@ -279,6 +314,7 @@ struct RecipeView: View {
@Published var presentShareSheet: Bool = false
@Published var presentKeywordSheet: Bool = false
@Published var presentMealPlanSheet: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
@@ -328,6 +364,7 @@ struct RecipeView: View {
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
@@ -474,6 +511,7 @@ struct RecipeViewToolBar: ToolbarContent {
}
await appState.getCategories()
await appState.getCategory(named: category, fetchMode: .preferServer)
mealPlan.removeAllAssignments(forRecipeId: String(id))
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}