From 8b23652f102e22faeb7c5a90f2b31c3efa415d08 Mon Sep 17 00:00:00 2001 From: Hendrik Hogertz Date: Sun, 15 Feb 2026 05:23:29 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 9 +- .../project.pbxproj | 20 + .../Data/MealPlanManager.swift | 197 +++++++++ .../Data/MealPlanModels.swift | 83 ++++ .../Data/MealPlanSyncManager.swift | 117 +++++ .../Data/ObservableRecipeDetail.swift | 6 +- .../Data/RecipeModels.swift | 8 +- .../Data/UserSettings.swift | 7 + .../Localizable.xcstrings | 204 +++++++++ .../Views/MainView.swift | 17 +- .../Views/Recipes/AddToMealPlanSheet.swift | 215 ++++++++++ .../Views/Recipes/AllRecipesListView.swift | 2 + .../Views/Recipes/RecipeListView.swift | 2 + .../Views/Recipes/RecipeView.swift | 38 ++ .../Views/Tabs/MealPlanTabView.swift | 406 ++++++++++++++++++ .../Views/Tabs/RecipeTabView.swift | 5 + .../Views/Tabs/SearchTabView.swift | 2 + 17 files changed, 1332 insertions(+), 6 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift create mode 100644 Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift create mode 100644 Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift create mode 100644 Nextcloud Cookbook iOS Client/Views/Recipes/AddToMealPlanSheet.swift create mode 100644 Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift diff --git a/CLAUDE.md b/CLAUDE.md index fb453de..1611ee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ Additional ViewModels exist as nested classes within their views (`RecipeTabView ``` SwiftUI Views ├── @EnvironmentObject appState: AppState - ├── @EnvironmentObject groceryList: GroceryList + ├── @EnvironmentObject groceryList: GroceryListManager + ├── @EnvironmentObject mealPlan: MealPlanManager └── Per-view @StateObject ViewModels │ ▼ @@ -66,6 +67,8 @@ AppState └── UserSettings.shared (UserDefaults singleton) ``` +Both `GroceryListManager` and `MealPlanManager` use custom metadata fields (`_groceryState`, `_mealPlanAssignment`) embedded in recipe JSON on the Nextcloud Cookbook API for cross-device sync. Each has a dedicated sync manager (`GroceryStateSyncManager`, `MealPlanSyncManager`) that handles debounced push, pull reconciliation, and per-item/per-date last-writer-wins merge. + ### Network Layer - `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods. @@ -83,11 +86,11 @@ AppState ``` Nextcloud Cookbook iOS Client/ -├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings +├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings + MealPlan + GroceryList ├── Models/ # RecipeEditViewModel ├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi ├── Views/ -│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab) +│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, MealPlanTab, GroceryListTab) │ ├── Recipes/ # Recipe detail, list, card, share, timer views │ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.) │ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView) diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 0969f9b..f6ccd62 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -74,6 +74,11 @@ D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; }; E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; }; E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; }; + F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE012E0C000100000001 /* MealPlanModels.swift */; }; + F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE032E0C000200000002 /* MealPlanManager.swift */; }; + F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; }; + F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; }; + F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -165,6 +170,11 @@ D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = ""; }; E1B0CF062D0B000400000004 /* GroceryStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateModels.swift; sourceTree = ""; }; E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = ""; }; + F1A0DE012E0C000100000001 /* MealPlanModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanModels.swift; sourceTree = ""; }; + F1A0DE032E0C000200000002 /* MealPlanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanManager.swift; sourceTree = ""; }; + F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = ""; }; + F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = ""; }; + F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -307,6 +317,9 @@ D1A0CE042D0A000300000003 /* GroceryListManager.swift */, E1B0CF062D0B000400000004 /* GroceryStateModels.swift */, E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */, + F1A0DE012E0C000100000001 /* MealPlanModels.swift */, + F1A0DE032E0C000200000002 /* MealPlanManager.swift */, + F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */, ); path = Data; sourceTree = ""; @@ -385,6 +398,7 @@ A977D0DD2B600300009783A9 /* SearchTabView.swift */, A977D0DF2B600318009783A9 /* RecipeTabView.swift */, A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, + F1A0DE072E0C000400000004 /* MealPlanTabView.swift */, ); path = Tabs; sourceTree = ""; @@ -415,6 +429,7 @@ A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */, C1F0AB012D0B000100000001 /* ImportURLSheet.swift */, + F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */, ); path = Recipes; sourceTree = ""; @@ -646,6 +661,11 @@ D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */, E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */, + F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */, + F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */, + F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */, + F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */, + F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift b/Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift new file mode 100644 index 0000000..3f6ab50 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift @@ -0,0 +1,197 @@ +// +// MealPlanManager.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation +import OSLog + +@MainActor +class MealPlanManager: ObservableObject { + @Published var entriesByDate: [String: [MealPlanEntry]] = [:] + + private var assignmentsByRecipe: [String: MealPlanAssignment] = [:] + private var recipeNames: [String: String] = [:] + private let dataStore = DataStore() + var syncManager: MealPlanSyncManager? + + private static let persistencePath = "meal_plan.data" + + // MARK: - Persistence + + struct PersistenceData: Codable { + var assignmentsByRecipe: [String: MealPlanAssignment] + var recipeNames: [String: String] + } + + func load() async { + do { + guard let data: PersistenceData = try await dataStore.load(fromPath: Self.persistencePath) else { return } + assignmentsByRecipe = data.assignmentsByRecipe + recipeNames = data.recipeNames + pruneOldEntries() + rebuildEntries() + } catch { + Logger.data.error("Unable to load meal plan data") + } + } + + func save() { + pruneOldEntries() + let data = PersistenceData(assignmentsByRecipe: assignmentsByRecipe, recipeNames: recipeNames) + Task { + await dataStore.save(data: data, toPath: Self.persistencePath) + } + } + + func configureSyncManager(appState: AppState) { + syncManager = MealPlanSyncManager(appState: appState, mealPlanManager: self) + } + + // MARK: - CRUD + + func assignRecipe(recipeId: String, recipeName: String, toDates dates: [Date]) { + recipeNames[recipeId] = recipeName + var assignment = assignmentsByRecipe[recipeId] ?? MealPlanAssignment() + + for date in dates { + let dayStr = MealPlanDate.dayString(from: date) + assignment.dates[dayStr] = MealPlanDateEntry(status: .assigned) + } + assignment.lastModified = MealPlanDate.now() + assignmentsByRecipe[recipeId] = assignment + + rebuildEntries() + save() + syncManager?.scheduleSync(forRecipeId: recipeId) + } + + func removeRecipe(recipeId: String, fromDate dateString: String) { + guard var assignment = assignmentsByRecipe[recipeId] else { return } + + assignment.dates[dateString] = MealPlanDateEntry(status: .removed) + assignment.lastModified = MealPlanDate.now() + assignmentsByRecipe[recipeId] = assignment + + rebuildEntries() + save() + syncManager?.scheduleSync(forRecipeId: recipeId) + } + + func removeAllAssignments(forRecipeId recipeId: String) { + guard var assignment = assignmentsByRecipe[recipeId] else { return } + + let now = MealPlanDate.now() + for key in assignment.dates.keys { + assignment.dates[key] = MealPlanDateEntry(status: .removed, modifiedAt: now) + } + assignment.lastModified = now + assignmentsByRecipe[recipeId] = assignment + + rebuildEntries() + save() + syncManager?.scheduleSync(forRecipeId: recipeId) + } + + // MARK: - Queries + + func entries(for date: Date) -> [MealPlanEntry] { + let dayStr = MealPlanDate.dayString(from: date) + return entriesByDate[dayStr] ?? [] + } + + func isRecipeAssigned(_ recipeId: String, on date: Date) -> Bool { + let dayStr = MealPlanDate.dayString(from: date) + guard let assignment = assignmentsByRecipe[recipeId], + let entry = assignment.dates[dayStr] else { return false } + return entry.status == .assigned + } + + func assignedDates(forRecipeId recipeId: String) -> [String] { + guard let assignment = assignmentsByRecipe[recipeId] else { return [] } + return assignment.dates.compactMap { key, entry in + entry.status == .assigned ? key : nil + } + } + + func assignment(forRecipeId recipeId: String) -> MealPlanAssignment? { + assignmentsByRecipe[recipeId] + } + + // MARK: - Reconciliation (Pull) + + func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) { + guard let serverAssignment, !serverAssignment.dates.isEmpty else { return } + + recipeNames[recipeId] = recipeName + var local = assignmentsByRecipe[recipeId] ?? MealPlanAssignment() + + for (dayStr, serverEntry) in serverAssignment.dates { + if let localEntry = local.dates[dayStr] { + let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast + let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast + if serverDate > localDate { + local.dates[dayStr] = serverEntry + } + } else { + local.dates[dayStr] = serverEntry + } + } + + local.lastModified = MealPlanDate.now() + assignmentsByRecipe[recipeId] = local + + rebuildEntries() + save() + } + + // MARK: - Internal + + private func pruneOldEntries() { + let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))! + var emptyRecipeIds: [String] = [] + + for (recipeId, var assignment) in assignmentsByRecipe { + assignment.dates = assignment.dates.filter { dayStr, _ in + guard let date = MealPlanDate.dateFromDay(dayStr) else { return true } + return date >= cutoff + } + if assignment.dates.isEmpty { + emptyRecipeIds.append(recipeId) + } else { + assignmentsByRecipe[recipeId] = assignment + } + } + + for recipeId in emptyRecipeIds { + assignmentsByRecipe.removeValue(forKey: recipeId) + recipeNames.removeValue(forKey: recipeId) + } + } + + private func rebuildEntries() { + var newEntries: [String: [MealPlanEntry]] = [:] + + for (recipeId, assignment) in assignmentsByRecipe { + let name = recipeNames[recipeId] ?? "Recipe \(recipeId)" + for (dayStr, entry) in assignment.dates where entry.status == .assigned { + guard let date = MealPlanDate.dateFromDay(dayStr) else { continue } + let mealEntry = MealPlanEntry( + recipeId: recipeId, + recipeName: name, + date: date, + dateString: dayStr, + mealType: entry.mealType + ) + newEntries[dayStr, default: []].append(mealEntry) + } + } + + // Sort entries within each day by recipe name + for key in newEntries.keys { + newEntries[key]?.sort(by: { $0.recipeName < $1.recipeName }) + } + + entriesByDate = newEntries + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift b/Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift new file mode 100644 index 0000000..fd906c6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift @@ -0,0 +1,83 @@ +// +// MealPlanModels.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation + +/// Tracks meal plan assignments for a recipe, stored as `_mealPlanAssignment` in the recipe JSON on the server. +struct MealPlanAssignment: Codable { + var version: Int = 1 + var lastModified: String + var dates: [String: MealPlanDateEntry] + + init(lastModified: String = MealPlanDate.now(), dates: [String: MealPlanDateEntry] = [:]) { + self.version = 1 + self.lastModified = lastModified + self.dates = dates + } +} + +struct MealPlanDateEntry: Codable { + enum Status: String, Codable { + case assigned + case removed + } + + var status: Status + var mealType: String? + var modifiedAt: String + + init(status: Status, mealType: String? = nil, modifiedAt: String = MealPlanDate.now()) { + self.status = status + self.mealType = mealType + self.modifiedAt = modifiedAt + } +} + +/// ISO 8601 date helpers for meal plan dates. +enum MealPlanDate { + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + return f + }() + + static func now() -> String { + isoFormatter.string(from: Date()) + } + + static func date(from string: String) -> Date? { + isoFormatter.date(from: string) + } + + static func string(from date: Date) -> String { + isoFormatter.string(from: date) + } + + static func dayString(from date: Date) -> String { + dayFormatter.string(from: date) + } + + static func dateFromDay(_ dayString: String) -> Date? { + dayFormatter.date(from: dayString) + } +} + +/// Local-only aggregated view struct used by the UI. +struct MealPlanEntry: Identifiable { + let recipeId: String + let recipeName: String + let date: Date + let dateString: String + let mealType: String? + + var id: String { "\(recipeId)-\(dateString)" } +} diff --git a/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift b/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift new file mode 100644 index 0000000..4f07b68 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift @@ -0,0 +1,117 @@ +// +// MealPlanSyncManager.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation +import OSLog + +@MainActor +class MealPlanSyncManager { + private weak var appState: AppState? + private weak var mealPlanManager: MealPlanManager? + + private var debounceTimers: [String: Task] = [:] + private let debounceInterval: TimeInterval = 2.0 + + init(appState: AppState, mealPlanManager: MealPlanManager) { + self.appState = appState + self.mealPlanManager = mealPlanManager + } + + // MARK: - Push Flow + + func scheduleSync(forRecipeId recipeId: String) { + guard UserSettings.shared.mealPlanSyncEnabled else { return } + + debounceTimers[recipeId]?.cancel() + debounceTimers[recipeId] = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(2_000_000_000)) + guard !Task.isCancelled else { return } + await self?.pushMealPlanState(forRecipeId: recipeId) + } + } + + func pushMealPlanState(forRecipeId recipeId: String) async { + guard let appState, let mealPlanManager else { return } + guard let recipeIdInt = Int(recipeId) else { return } + + let localAssignment = mealPlanManager.assignment(forRecipeId: recipeId) + + guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else { + Logger.data.error("Meal plan sync: failed to fetch recipe \(recipeId) from server") + return + } + + let merged = mergeAssignments(local: localAssignment, server: serverRecipe.mealPlanAssignment) + + var updatedRecipe = serverRecipe + updatedRecipe.mealPlanAssignment = merged + let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false) + if let alert { + Logger.data.error("Meal plan sync: failed to push state for recipe \(recipeId): \(String(describing: alert))") + } + } + + // MARK: - Pull Flow + + func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) { + guard let mealPlanManager else { return } + mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName) + } + + // MARK: - Initial Sync + + func performInitialSync() async { + guard let appState, let mealPlanManager else { return } + + let recipeIds = Array(mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId)) + let uniqueIds = Array(Set(recipeIds)) + + for recipeId in uniqueIds { + guard let recipeIdInt = Int(recipeId) else { continue } + + await pushMealPlanState(forRecipeId: recipeId) + + if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) { + reconcileFromServer( + serverAssignment: serverRecipe.mealPlanAssignment, + recipeId: recipeId, + recipeName: serverRecipe.name + ) + } + } + } + + // MARK: - Merge Logic + + private func mergeAssignments(local: MealPlanAssignment?, server: MealPlanAssignment?) -> MealPlanAssignment { + guard let local else { return server ?? MealPlanAssignment() } + guard let server else { return local } + + var merged = local.dates + for (dayStr, serverEntry) in server.dates { + if let localEntry = merged[dayStr] { + let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast + let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast + if serverDate > localDate { + merged[dayStr] = serverEntry + } + } else { + merged[dayStr] = serverEntry + } + } + + // Prune all date entries older than 30 days + let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))! + merged = merged.filter { dayStr, _ in + guard let date = MealPlanDate.dateFromDay(dayStr) else { return true } + return date >= cutoff + } + + return MealPlanAssignment( + lastModified: MealPlanDate.now(), + dates: merged + ) + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index a4d2de6..12fe2a9 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -27,6 +27,7 @@ class ObservableRecipeDetail: ObservableObject { @Published var recipeInstructions: [String] @Published var nutrition: [String:String] var groceryState: GroceryState? + var mealPlanAssignment: MealPlanAssignment? // Additional functionality @Published var ingredientMultiplier: Double @@ -50,6 +51,7 @@ class ObservableRecipeDetail: ObservableObject { recipeInstructions = [] nutrition = [:] groceryState = nil + mealPlanAssignment = nil ingredientMultiplier = 1 } @@ -71,6 +73,7 @@ class ObservableRecipeDetail: ObservableObject { recipeInstructions = recipeDetail.recipeInstructions nutrition = recipeDetail.nutrition groceryState = recipeDetail.groceryState + mealPlanAssignment = recipeDetail.mealPlanAssignment ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) } @@ -94,7 +97,8 @@ class ObservableRecipeDetail: ObservableObject { recipeIngredient: self.recipeIngredient, recipeInstructions: self.recipeInstructions, nutrition: self.nutrition, - groceryState: self.groceryState + groceryState: self.groceryState, + mealPlanAssignment: self.mealPlanAssignment ) } diff --git a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift index f46bb94..f6f8aed 100644 --- a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift @@ -51,8 +51,9 @@ struct RecipeDetail: Codable { var recipeInstructions: [String] var nutrition: [String:String] var groceryState: GroceryState? + var mealPlanAssignment: MealPlanAssignment? - init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String], groceryState: GroceryState? = nil) { + init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String], groceryState: GroceryState? = nil, mealPlanAssignment: MealPlanAssignment? = nil) { self.name = name self.keywords = keywords self.dateCreated = dateCreated @@ -71,6 +72,7 @@ struct RecipeDetail: Codable { self.recipeInstructions = recipeInstructions self.nutrition = nutrition self.groceryState = groceryState + self.mealPlanAssignment = mealPlanAssignment } init() { @@ -92,12 +94,14 @@ struct RecipeDetail: Codable { recipeInstructions = [] nutrition = [:] groceryState = nil + mealPlanAssignment = nil } // Custom decoder to handle value type ambiguity private enum CodingKeys: String, CodingKey { case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition case groceryState = "_groceryState" + case mealPlanAssignment = "_mealPlanAssignment" } init(from decoder: Decoder) throws { @@ -138,6 +142,7 @@ struct RecipeDetail: Codable { } groceryState = try? container.decode(GroceryState.self, forKey: .groceryState) + mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment) } func encode(to encoder: Encoder) throws { @@ -161,6 +166,7 @@ struct RecipeDetail: Codable { try container.encode(recipeInstructions, forKey: .recipeInstructions) try container.encode(nutrition, forKey: .nutrition) try container.encodeIfPresent(groceryState, forKey: .groceryState) + try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment) } } diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index ed3cd69..b978aa0 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -138,6 +138,12 @@ class UserSettings: ObservableObject { UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled") } } + + @Published var mealPlanSyncEnabled: Bool { + didSet { + UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled") + } + } init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" @@ -161,6 +167,7 @@ class UserSettings: ObservableObject { self.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? "" self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true + self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 0edffef..8424d72 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -2514,6 +2514,28 @@ } } }, + "Last Week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzte Woche" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semana Pasada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semaine Dernière" + } + } + } + }, "List your tools here. 🍴" : { "extractionState" : "stale", "localizations" : { @@ -2669,6 +2691,28 @@ } } }, + "Meal Plan" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essensplan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan de Comidas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan de Repas" + } + } + } + }, "Minutes" : { "localizations" : { "de" : { @@ -2870,6 +2914,28 @@ } } }, + "Next Week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächste Woche" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Próxima Semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semaine Prochaine" + } + } + } + }, "Nextcloud Login" : { "localizations" : { "de" : { @@ -3293,6 +3359,28 @@ } } }, + "Plan recipe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezept einplanen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planificar receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planifier la recette" + } + } + } + }, "Please check the entered URL." : { "extractionState" : "stale", "localizations" : { @@ -3690,6 +3778,30 @@ } } }, + "Remove" : { + "comment" : "A menu item that allows a user to remove an item from a meal plan.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + } + } + }, "Remove from Grocery List" : { "localizations" : { "de" : { @@ -3757,6 +3869,28 @@ } } }, + "Schedule Recipe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezept einplanen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programar Receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planifier la Recette" + } + } + } + }, "Search" : { "localizations" : { "de" : { @@ -3813,6 +3947,30 @@ } } }, + "Search recipes" : { + "comment" : "A prompt for searching recipes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezepte suchen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar recetas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des recettes" + } + } + } + }, "Search recipes/keywords" : { "localizations" : { "de" : { @@ -4572,6 +4730,28 @@ } } }, + "This Week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Woche" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta Semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette Semaine" + } + } + } + }, "Title" : { "extractionState" : "stale", "localizations" : { @@ -4595,6 +4775,30 @@ } } }, + "Today" : { + "comment" : "Suffix added to the name of a day when it is the current day.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui" + } + } + } + }, "Tool" : { "localizations" : { "de" : { diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index d0ce254..03ab322 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -10,6 +10,7 @@ import SwiftUI struct MainView: View { @StateObject var appState = AppState() @StateObject var groceryList = GroceryListManager() + @StateObject var mealPlan = MealPlanManager() // Tab ViewModels @StateObject var recipeViewModel = RecipeTabView.ViewModel() @@ -20,7 +21,7 @@ struct MainView: View { @State private var selectedTab: Tab = .recipes enum Tab { - case recipes, search, groceryList + case recipes, search, mealPlan, groceryList } var body: some View { @@ -30,6 +31,7 @@ struct MainView: View { .environmentObject(recipeViewModel) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) } SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) { @@ -37,6 +39,14 @@ struct MainView: View { .environmentObject(searchViewModel) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) + } + + SwiftUI.Tab("Meal Plan", systemImage: "calendar", value: .mealPlan) { + MealPlanTabView() + .environmentObject(mealPlan) + .environmentObject(appState) + .environmentObject(groceryList) } if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue { @@ -85,6 +95,11 @@ struct MainView: View { if UserSettings.shared.grocerySyncEnabled { await groceryList.syncManager?.performInitialSync() } + await mealPlan.load() + mealPlan.configureSyncManager(appState: appState) + if UserSettings.shared.mealPlanSyncEnabled { + await mealPlan.syncManager?.performInitialSync() + } recipeViewModel.presentLoadingIndicator = false } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AddToMealPlanSheet.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AddToMealPlanSheet.swift new file mode 100644 index 0000000..e9510a3 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AddToMealPlanSheet.swift @@ -0,0 +1,215 @@ +// +// AddToMealPlanSheet.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation +import SwiftUI + +struct AddToMealPlanSheet: View { + @EnvironmentObject var mealPlan: MealPlanManager + @Environment(\.dismiss) private var dismiss + + let recipeId: String + let recipeName: String + let prepTime: String? + let recipeImage: UIImage? + + @State private var weekOffset: Int = 0 + @State private var selectedDays: Set = [] + + private var calendar: Calendar { Calendar.current } + + private var weekDates: [Date] { + let today = calendar.startOfDay(for: Date()) + let weekday = calendar.component(.weekday, from: today) + let daysToMonday = (weekday + 5) % 7 + guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today), + let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else { + return [] + } + return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) } + } + + private var weekLabel: String { + if weekOffset == 0 { + return String(localized: "This Week") + } else if weekOffset == 1 { + return String(localized: "Next Week") + } else if weekOffset == -1 { + return String(localized: "Last Week") + } else { + return weekRangeString + } + } + + private var weekRangeString: String { + guard let first = weekDates.first, let last = weekDates.last else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM." + return "\(formatter.string(from: first)) – \(formatter.string(from: last))" + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Recipe header + recipeHeader + .padding() + + Divider() + + // Week navigation + weekNavigationHeader + .padding(.horizontal) + .padding(.vertical, 8) + + // Day rows with checkboxes + List { + ForEach(weekDates, id: \.self) { date in + let dayStr = MealPlanDate.dayString(from: date) + let isAlreadyAssigned = mealPlan.isRecipeAssigned(recipeId, on: date) + let existingCount = mealPlan.entries(for: date).count + + Button { + if !isAlreadyAssigned { + if selectedDays.contains(dayStr) { + selectedDays.remove(dayStr) + } else { + selectedDays.insert(dayStr) + } + } + } label: { + HStack { + Image(systemName: (isAlreadyAssigned || selectedDays.contains(dayStr)) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isAlreadyAssigned ? Color.secondary : Color.nextcloudBlue) + + Text(dayDisplayName(date)) + .foregroundStyle(isAlreadyAssigned ? .secondary : .primary) + + Spacer() + + if existingCount > 0 { + Text("\(existingCount)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color(.tertiarySystemFill))) + } + } + } + .disabled(isAlreadyAssigned) + } + } + .listStyle(.plain) + } + .navigationTitle("Schedule Recipe") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + let dates = selectedDays.compactMap { MealPlanDate.dateFromDay($0) } + if !dates.isEmpty { + mealPlan.assignRecipe(recipeId: recipeId, recipeName: recipeName, toDates: dates) + } + dismiss() + } + .disabled(selectedDays.isEmpty) + } + } + } + } + + private var recipeHeader: some View { + HStack(spacing: 12) { + if let recipeImage { + Image(uiImage: recipeImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + LinearGradient( + gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "fork.knife") + .foregroundStyle(.white.opacity(0.7)) + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(recipeName) + .font(.headline) + .lineLimit(2) + + if let prepTime, !prepTime.isEmpty { + let duration = DurationComponents.fromPTString(prepTime) + if duration.hourComponent > 0 || duration.minuteComponent > 0 { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.caption) + Text(duration.displayString) + .font(.caption) + } + .foregroundStyle(.secondary) + } + } + } + + Spacer() + } + } + + private var weekNavigationHeader: some View { + HStack { + Button { + withAnimation { weekOffset -= 1 } + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundStyle(Color.nextcloudBlue) + } + + Spacer() + + VStack(spacing: 2) { + Text(weekLabel) + .font(.headline) + if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 { + Text(weekRangeString) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button { + withAnimation { weekOffset += 1 } + } label: { + Image(systemName: "chevron.right") + .font(.title3) + .foregroundStyle(Color.nextcloudBlue) + } + } + } + + private func dayDisplayName(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, d MMM" + let name = formatter.string(from: date) + if calendar.isDateInToday(date) { + return "\(name) (\(String(localized: "Today")))" + } + return name + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift index bf46d44..e426680 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift @@ -8,6 +8,7 @@ import SwiftUI struct AllRecipesListView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryListManager + @EnvironmentObject var mealPlan: MealPlanManager var onCreateNew: () -> Void var onImportFromURL: () -> Void @State private var allRecipes: [Recipe] = [] @@ -65,6 +66,7 @@ struct AllRecipesListView: View { RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) } .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index e83d867..0cf1f59 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -13,6 +13,7 @@ import SwiftUI struct RecipeListView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryListManager + @EnvironmentObject var mealPlan: MealPlanManager @State var categoryName: String @State var searchText: String = "" var onCreateNew: () -> Void @@ -78,6 +79,7 @@ struct RecipeListView: View { RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) } .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index be6f393..02e24d0 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -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() } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift new file mode 100644 index 0000000..31d64b6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift @@ -0,0 +1,406 @@ +// +// MealPlanTabView.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation +import SwiftUI + +struct MealPlanTabView: View { + @EnvironmentObject var mealPlan: MealPlanManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var groceryList: GroceryListManager + + @State private var weekOffset: Int = 0 + @State private var addRecipeDate: Date? = nil + + private var calendar: Calendar { Calendar.current } + + private var weekDates: [Date] { + let today = calendar.startOfDay(for: Date()) + // Find start of current week (Monday) + let weekday = calendar.component(.weekday, from: today) + let daysToMonday = (weekday + 5) % 7 + guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today), + let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else { + return [] + } + return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) } + } + + private var weekLabel: String { + if weekOffset == 0 { + return String(localized: "This Week") + } else if weekOffset == 1 { + return String(localized: "Next Week") + } else if weekOffset == -1 { + return String(localized: "Last Week") + } else { + return weekRangeString + } + } + + private var weekRangeString: String { + guard let first = weekDates.first, let last = weekDates.last else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM." + return "\(formatter.string(from: first)) – \(formatter.string(from: last))" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 0) { + weekNavigationHeader + .padding(.horizontal) + .padding(.vertical, 8) + + ForEach(weekDates, id: \.self) { date in + MealPlanDayRow( + date: date, + entries: mealPlan.entries(for: date), + isToday: calendar.isDateInToday(date), + onAdd: { + addRecipeDate = date + }, + onRemove: { entry in + withAnimation { + mealPlan.removeRecipe(recipeId: entry.recipeId, fromDate: entry.dateString) + } + } + ) + } + } + } + .navigationTitle("Meal Plan") + .navigationDestination(for: Recipe.self) { recipe in + RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) + .environmentObject(appState) + .environmentObject(groceryList) + .environmentObject(mealPlan) + } + .sheet(item: $addRecipeDate) { date in + RecipePickerForMealPlan(date: date) + .environmentObject(mealPlan) + .environmentObject(appState) + } + } + } + + private var weekNavigationHeader: some View { + HStack { + Button { + withAnimation { weekOffset -= 1 } + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundStyle(Color.nextcloudBlue) + } + + Spacer() + + VStack(spacing: 2) { + Text(weekLabel) + .font(.headline) + if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 { + Text(weekRangeString) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button { + withAnimation { weekOffset += 1 } + } label: { + Image(systemName: "chevron.right") + .font(.title3) + .foregroundStyle(Color.nextcloudBlue) + } + } + } +} + + +// MARK: - Day Row + +fileprivate struct MealPlanDayRow: View { + let date: Date + let entries: [MealPlanEntry] + let isToday: Bool + let onAdd: () -> Void + let onRemove: (MealPlanEntry) -> Void + + private var dayNumber: String { + let formatter = DateFormatter() + formatter.dateFormat = "d" + return formatter.string(from: date) + } + + private var dayName: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + return formatter.string(from: date).uppercased() + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Day label + VStack(spacing: 2) { + Text(dayName) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(isToday ? .white : .secondary) + Text(dayNumber) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isToday ? .white : .primary) + } + .frame(width: 44, height: 54) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isToday ? Color.nextcloudBlue : Color.clear) + ) + + // Entry or add button + if let entry = entries.first, let recipeIdInt = Int(entry.recipeId) { + NavigationLink(value: Recipe( + name: entry.recipeName, + keywords: nil, + dateCreated: nil, + dateModified: nil, + imageUrl: nil, + imagePlaceholderUrl: nil, + recipe_id: recipeIdInt + )) { + MealPlanEntryCard(entry: entry, onRemove: { + onRemove(entry) + }) + } + .buttonStyle(.plain) + } else if let entry = entries.first { + MealPlanEntryCard(entry: entry, onRemove: { + onRemove(entry) + }) + } else { + Button(action: onAdd) { + Image(systemName: "plus") + .font(.subheadline) + .foregroundStyle(Color.nextcloudBlue) + .frame(maxWidth: .infinity, minHeight: 44) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.nextcloudBlue.opacity(0.1)) + ) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + .padding(.leading, 68) + } +} + + +// MARK: - Entry Card + +fileprivate struct MealPlanEntryCard: View { + @EnvironmentObject var appState: AppState + let entry: MealPlanEntry + let onRemove: () -> Void + + @State private var recipeThumb: UIImage? + @State private var totalTimeText: String? + + var body: some View { + HStack(spacing: 8) { + if let recipeThumb { + Image(uiImage: recipeThumb) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 44) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + LinearGradient( + gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: 44) + .overlay { + Image(systemName: "fork.knife") + .font(.caption2) + .foregroundStyle(.white.opacity(0.7)) + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + VStack(alignment: .leading, spacing: 2) { + Text(entry.recipeName) + .font(.subheadline) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + + if let totalTimeText { + HStack(spacing: 3) { + Image(systemName: "clock") + .font(.caption2) + Text(totalTimeText) + .font(.caption2) + } + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(.secondarySystemBackground)) + ) + .contextMenu { + Button(role: .destructive) { + onRemove() + } label: { + Label("Remove", systemImage: "trash") + } + } + .task { + guard let recipeIdInt = Int(entry.recipeId) else { return } + recipeThumb = await appState.getImage( + id: recipeIdInt, + size: .THUMB, + fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer + ) + if let detail = await appState.getRecipe( + id: recipeIdInt, + fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer + ) { + if let totalTime = detail.totalTime, let text = DurationComponents.ptToText(totalTime) { + totalTimeText = text + } else if let prepTime = detail.prepTime, let text = DurationComponents.ptToText(prepTime) { + totalTimeText = text + } + } + } + } +} + + +// MARK: - Recipe Picker Sheet + +struct RecipePickerForMealPlan: View { + @EnvironmentObject var mealPlan: MealPlanManager + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + let date: Date + + @State private var searchText = "" + @State private var allRecipes: [Recipe] = [] + + private var filteredRecipes: [Recipe] { + if searchText.isEmpty { + return allRecipes + } + return allRecipes.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + + private var dateLabel: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: date) + } + + var body: some View { + NavigationStack { + List { + ForEach(filteredRecipes, id: \.recipe_id) { recipe in + Button { + mealPlan.assignRecipe( + recipeId: String(recipe.recipe_id), + recipeName: recipe.name, + toDates: [date] + ) + dismiss() + } label: { + RecipePickerRow(recipe: recipe) + } + } + } + .navigationTitle(dateLabel) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: String(localized: "Search recipes")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { + allRecipes = await appState.getRecipes() + } + } +} + + +// MARK: - Recipe Picker Row + +fileprivate struct RecipePickerRow: View { + @EnvironmentObject var appState: AppState + let recipe: Recipe + @State private var recipeThumb: UIImage? + + var body: some View { + HStack(spacing: 10) { + if let recipeThumb { + Image(uiImage: recipeThumb) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + LinearGradient( + gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: 48, height: 48) + .overlay { + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + Text(recipe.name) + .font(.subheadline) + .foregroundStyle(.primary) + .lineLimit(2) + + Spacer() + } + .task { + recipeThumb = await appState.getImage( + id: recipe.recipe_id, + size: .THUMB, + fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer + ) + } + } +} + + +// MARK: - Date Identifiable Extension + +extension Date: @retroactive Identifiable { + public var id: TimeInterval { timeIntervalSince1970 } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 5128fe5..b729f6c 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -13,6 +13,7 @@ import SwiftUI struct RecipeTabView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryListManager + @EnvironmentObject var mealPlan: MealPlanManager @EnvironmentObject var viewModel: RecipeTabView.ViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -114,6 +115,7 @@ struct RecipeTabView: View { }()) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) .onAppear { viewModel.importedRecipeDetail = nil } @@ -126,6 +128,7 @@ struct RecipeTabView: View { .id(category.id) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) case .allRecipes: AllRecipesListView( onCreateNew: { viewModel.navigateToNewRecipe() }, @@ -133,12 +136,14 @@ struct RecipeTabView: View { ) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) } } .navigationDestination(for: Recipe.self) { recipe in RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) .environmentObject(appState) .environmentObject(groceryList) + .environmentObject(mealPlan) } } } detail: { diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift index d448069..8be4100 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -11,6 +11,7 @@ import SwiftUI struct SearchTabView: View { @EnvironmentObject var viewModel: SearchTabView.ViewModel @EnvironmentObject var appState: AppState + @EnvironmentObject var mealPlan: MealPlanManager var body: some View { NavigationStack { @@ -113,6 +114,7 @@ struct SearchTabView: View { .navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results") .navigationDestination(for: Recipe.self) { recipe in RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) + .environmentObject(mealPlan) } .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") .onSubmit(of: .search) {