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

@@ -56,7 +56,8 @@ Additional ViewModels exist as nested classes within their views (`RecipeTabView
``` ```
SwiftUI Views SwiftUI Views
├── @EnvironmentObject appState: AppState ├── @EnvironmentObject appState: AppState
├── @EnvironmentObject groceryList: GroceryList ├── @EnvironmentObject groceryList: GroceryListManager
├── @EnvironmentObject mealPlan: MealPlanManager
└── Per-view @StateObject ViewModels └── Per-view @StateObject ViewModels
@@ -66,6 +67,8 @@ AppState
└── UserSettings.shared (UserDefaults singleton) └── 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 ### Network Layer
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods. - `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
@@ -83,11 +86,11 @@ AppState
``` ```
Nextcloud Cookbook iOS Client/ Nextcloud Cookbook iOS Client/
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings ├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings + MealPlan + GroceryList
├── Models/ # RecipeEditViewModel ├── Models/ # RecipeEditViewModel
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi ├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
├── Views/ ├── Views/
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab) │ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, MealPlanTab, GroceryListTab)
│ ├── Recipes/ # Recipe detail, list, card, share, timer views │ ├── Recipes/ # Recipe detail, list, card, share, timer views
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.) │ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView) │ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)

View File

@@ -74,6 +74,11 @@
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; }; D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; };
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; }; E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; };
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -165,6 +170,11 @@
D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; }; D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; };
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateModels.swift; sourceTree = "<group>"; }; E1B0CF062D0B000400000004 /* GroceryStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateModels.swift; sourceTree = "<group>"; };
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; }; E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; };
F1A0DE012E0C000100000001 /* MealPlanModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanModels.swift; sourceTree = "<group>"; };
F1A0DE032E0C000200000002 /* MealPlanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanManager.swift; sourceTree = "<group>"; };
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -307,6 +317,9 @@
D1A0CE042D0A000300000003 /* GroceryListManager.swift */, D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */, E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */, E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -385,6 +398,7 @@
A977D0DD2B600300009783A9 /* SearchTabView.swift */, A977D0DD2B600300009783A9 /* SearchTabView.swift */,
A977D0DF2B600318009783A9 /* RecipeTabView.swift */, A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */,
); );
path = Tabs; path = Tabs;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -415,6 +429,7 @@
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
A97B4D342B80B82A00EC1A88 /* ShareView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */, C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
); );
path = Recipes; path = Recipes;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -646,6 +661,11 @@
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */, E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.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; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -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
}
}

View File

@@ -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)" }
}

View File

@@ -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<Void, Never>] = [:]
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
)
}
}

View File

@@ -27,6 +27,7 @@ class ObservableRecipeDetail: ObservableObject {
@Published var recipeInstructions: [String] @Published var recipeInstructions: [String]
@Published var nutrition: [String:String] @Published var nutrition: [String:String]
var groceryState: GroceryState? var groceryState: GroceryState?
var mealPlanAssignment: MealPlanAssignment?
// Additional functionality // Additional functionality
@Published var ingredientMultiplier: Double @Published var ingredientMultiplier: Double
@@ -50,6 +51,7 @@ class ObservableRecipeDetail: ObservableObject {
recipeInstructions = [] recipeInstructions = []
nutrition = [:] nutrition = [:]
groceryState = nil groceryState = nil
mealPlanAssignment = nil
ingredientMultiplier = 1 ingredientMultiplier = 1
} }
@@ -71,6 +73,7 @@ class ObservableRecipeDetail: ObservableObject {
recipeInstructions = recipeDetail.recipeInstructions recipeInstructions = recipeDetail.recipeInstructions
nutrition = recipeDetail.nutrition nutrition = recipeDetail.nutrition
groceryState = recipeDetail.groceryState groceryState = recipeDetail.groceryState
mealPlanAssignment = recipeDetail.mealPlanAssignment
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
} }
@@ -94,7 +97,8 @@ class ObservableRecipeDetail: ObservableObject {
recipeIngredient: self.recipeIngredient, recipeIngredient: self.recipeIngredient,
recipeInstructions: self.recipeInstructions, recipeInstructions: self.recipeInstructions,
nutrition: self.nutrition, nutrition: self.nutrition,
groceryState: self.groceryState groceryState: self.groceryState,
mealPlanAssignment: self.mealPlanAssignment
) )
} }

View File

@@ -51,8 +51,9 @@ struct RecipeDetail: Codable {
var recipeInstructions: [String] var recipeInstructions: [String]
var nutrition: [String:String] var nutrition: [String:String]
var groceryState: GroceryState? 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.name = name
self.keywords = keywords self.keywords = keywords
self.dateCreated = dateCreated self.dateCreated = dateCreated
@@ -71,6 +72,7 @@ struct RecipeDetail: Codable {
self.recipeInstructions = recipeInstructions self.recipeInstructions = recipeInstructions
self.nutrition = nutrition self.nutrition = nutrition
self.groceryState = groceryState self.groceryState = groceryState
self.mealPlanAssignment = mealPlanAssignment
} }
init() { init() {
@@ -92,12 +94,14 @@ struct RecipeDetail: Codable {
recipeInstructions = [] recipeInstructions = []
nutrition = [:] nutrition = [:]
groceryState = nil groceryState = nil
mealPlanAssignment = nil
} }
// Custom decoder to handle value type ambiguity // Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey { 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 name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
case groceryState = "_groceryState" case groceryState = "_groceryState"
case mealPlanAssignment = "_mealPlanAssignment"
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -138,6 +142,7 @@ struct RecipeDetail: Codable {
} }
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState) groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -161,6 +166,7 @@ struct RecipeDetail: Codable {
try container.encode(recipeInstructions, forKey: .recipeInstructions) try container.encode(recipeInstructions, forKey: .recipeInstructions)
try container.encode(nutrition, forKey: .nutrition) try container.encode(nutrition, forKey: .nutrition)
try container.encodeIfPresent(groceryState, forKey: .groceryState) try container.encodeIfPresent(groceryState, forKey: .groceryState)
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
} }
} }

View File

@@ -138,6 +138,12 @@ class UserSettings: ObservableObject {
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled") UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
} }
} }
@Published var mealPlanSyncEnabled: Bool {
didSet {
UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled")
}
}
init() { init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" 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.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? "" self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
if authString == "" { if authString == "" {
if token != "" && username != "" { if token != "" && username != "" {

View File

@@ -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. 🍴" : { "List your tools here. 🍴" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "Minutes" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Nextcloud Login" : {
"localizations" : { "localizations" : {
"de" : { "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." : { "Please check the entered URL." : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "Remove from Grocery List" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Search" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Search recipes/keywords" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Title" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "Tool" : {
"localizations" : { "localizations" : {
"de" : { "de" : {

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@StateObject var appState = AppState() @StateObject var appState = AppState()
@StateObject var groceryList = GroceryListManager() @StateObject var groceryList = GroceryListManager()
@StateObject var mealPlan = MealPlanManager()
// Tab ViewModels // Tab ViewModels
@StateObject var recipeViewModel = RecipeTabView.ViewModel() @StateObject var recipeViewModel = RecipeTabView.ViewModel()
@@ -20,7 +21,7 @@ struct MainView: View {
@State private var selectedTab: Tab = .recipes @State private var selectedTab: Tab = .recipes
enum Tab { enum Tab {
case recipes, search, groceryList case recipes, search, mealPlan, groceryList
} }
var body: some View { var body: some View {
@@ -30,6 +31,7 @@ struct MainView: View {
.environmentObject(recipeViewModel) .environmentObject(recipeViewModel)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
} }
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) { SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
@@ -37,6 +39,14 @@ struct MainView: View {
.environmentObject(searchViewModel) .environmentObject(searchViewModel)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .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 { if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue {
@@ -85,6 +95,11 @@ struct MainView: View {
if UserSettings.shared.grocerySyncEnabled { if UserSettings.shared.grocerySyncEnabled {
await groceryList.syncManager?.performInitialSync() await groceryList.syncManager?.performInitialSync()
} }
await mealPlan.load()
mealPlan.configureSyncManager(appState: appState)
if UserSettings.shared.mealPlanSyncEnabled {
await mealPlan.syncManager?.performInitialSync()
}
recipeViewModel.presentLoadingIndicator = false recipeViewModel.presentLoadingIndicator = false
} }
} }

View File

@@ -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<String> = []
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
}
}

View File

@@ -8,6 +8,7 @@ import SwiftUI
struct AllRecipesListView: View { struct AllRecipesListView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
var onCreateNew: () -> Void var onCreateNew: () -> Void
var onImportFromURL: () -> Void var onImportFromURL: () -> Void
@State private var allRecipes: [Recipe] = [] @State private var allRecipes: [Recipe] = []
@@ -65,6 +66,7 @@ struct AllRecipesListView: View {
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {

View File

@@ -13,6 +13,7 @@ import SwiftUI
struct RecipeListView: View { struct RecipeListView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@State var categoryName: String @State var categoryName: String
@State var searchText: String = "" @State var searchText: String = ""
var onCreateNew: () -> Void var onCreateNew: () -> Void
@@ -78,6 +79,7 @@ struct RecipeListView: View {
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {

View File

@@ -13,6 +13,7 @@ import SwiftUI
struct RecipeView: View { struct RecipeView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero @GestureState private var dragOffset = CGSize.zero
@@ -50,6 +51,15 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentKeywordSheet) { .sheet(isPresented: $viewModel.presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) 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 { .task {
// Load recipe detail // Load recipe detail
if !viewModel.newRecipe { 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 { } else {
// Prepare view for a new recipe // Prepare view for a new recipe
if let preloaded = viewModel.preloadedRecipeDetail { if let preloaded = viewModel.preloadedRecipeDetail {
@@ -196,6 +215,22 @@ struct RecipeView: View {
RecipeDurationSection(viewModel: viewModel) 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() Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
@@ -279,6 +314,7 @@ struct RecipeView: View {
@Published var presentShareSheet: Bool = false @Published var presentShareSheet: Bool = false
@Published var presentKeywordSheet: Bool = false @Published var presentKeywordSheet: Bool = false
@Published var presentMealPlanSheet: Bool = false
var recipe: Recipe var recipe: Recipe
var sharedURL: URL? = nil var sharedURL: URL? = nil
@@ -328,6 +364,7 @@ struct RecipeView: View {
struct RecipeViewToolBar: ToolbarContent { struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
@@ -474,6 +511,7 @@ struct RecipeViewToolBar: ToolbarContent {
} }
await appState.getCategories() await appState.getCategories()
await appState.getCategory(named: category, fetchMode: .preferServer) await appState.getCategory(named: category, fetchMode: .preferServer)
mealPlan.removeAllAssignments(forRecipeId: String(id))
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss() dismiss()
} }

View File

@@ -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 }
}

View File

@@ -13,6 +13,7 @@ import SwiftUI
struct RecipeTabView: View { struct RecipeTabView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -114,6 +115,7 @@ struct RecipeTabView: View {
}()) }())
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
.onAppear { .onAppear {
viewModel.importedRecipeDetail = nil viewModel.importedRecipeDetail = nil
} }
@@ -126,6 +128,7 @@ struct RecipeTabView: View {
.id(category.id) .id(category.id)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
case .allRecipes: case .allRecipes:
AllRecipesListView( AllRecipesListView(
onCreateNew: { viewModel.navigateToNewRecipe() }, onCreateNew: { viewModel.navigateToNewRecipe() },
@@ -133,12 +136,14 @@ struct RecipeTabView: View {
) )
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
} }
} }
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.environmentObject(mealPlan)
} }
} }
} detail: { } detail: {

View File

@@ -11,6 +11,7 @@ import SwiftUI
struct SearchTabView: View { struct SearchTabView: View {
@EnvironmentObject var viewModel: SearchTabView.ViewModel @EnvironmentObject var viewModel: SearchTabView.ViewModel
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var mealPlan: MealPlanManager
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -113,6 +114,7 @@ struct SearchTabView: View {
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results") .navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(mealPlan)
} }
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
.onSubmit(of: .search) { .onSubmit(of: .search) {