From 5890dbcad482bf8d6856ce4eec09e4ae4dbc5749 Mon Sep 17 00:00:00 2001 From: Hendrik Hogertz Date: Sun, 15 Feb 2026 04:14:02 +0100 Subject: [PATCH] Add cross-device grocery list sync via Nextcloud Cookbook API Store a _groceryState JSON field on each recipe to track which ingredients have been added, completed, or removed. Uses per-item last-writer-wins conflict resolution with ISO 8601 timestamps. Debounced push (2s) avoids excessive API calls; pull reconciles on recipe open and app launch. Includes a settings toggle to enable/disable sync. Co-Authored-By: Claude Sonnet 4.5 --- .../project.pbxproj | 8 + .../Data/GroceryListManager.swift | 37 +++- .../Data/GroceryStateModels.swift | 58 ++++++ .../Data/GroceryStateSyncManager.swift | 168 ++++++++++++++++++ .../Data/ObservableRecipeDetail.swift | 12 +- .../Data/RecipeModels.swift | 15 +- .../Data/UserSettings.swift | 7 + .../Localizable.xcstrings | 6 + .../Views/MainView.swift | 4 + .../Views/Recipes/RecipeView.swift | 10 ++ .../Views/SettingsView.swift | 8 +- 11 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift create mode 100644 Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index c0d68ff..0969f9b 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -72,6 +72,8 @@ D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; }; D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; }; 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -161,6 +163,8 @@ D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = ""; }; D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -301,6 +305,8 @@ D1A0CE002D0A000100000001 /* GroceryListMode.swift */, D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */, D1A0CE042D0A000300000003 /* GroceryListManager.swift */, + E1B0CF062D0B000400000004 /* GroceryStateModels.swift */, + E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */, ); path = Data; sourceTree = ""; @@ -638,6 +644,8 @@ D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */, D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */, D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, + E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */, + E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift b/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift index 98e6af3..52358db 100644 --- a/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift +++ b/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift @@ -14,6 +14,11 @@ class GroceryListManager: ObservableObject { let localStore = GroceryList() let remindersStore = RemindersGroceryStore() + var syncManager: GroceryStateSyncManager? + + /// Recipe IDs modified by our own CRUD — skip these in the onDataChanged callback + /// to avoid duplicate syncs. + private var recentlyModifiedByUs: Set = [] private var mode: GroceryListMode { GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp @@ -23,11 +28,29 @@ class GroceryListManager: ObservableObject { remindersStore.onDataChanged = { [weak self] in guard let self else { return } if self.mode == .appleReminders { + let previousDict = self.groceryDict self.groceryDict = self.remindersStore.groceryDict + + // Only sync recipes that changed externally (e.g. checked off in Reminders app), + // not ones we just modified ourselves. + for recipeId in self.remindersStore.groceryDict.keys { + guard !self.recentlyModifiedByUs.contains(recipeId) else { continue } + // Detect if item count changed (external add/remove/complete) + let oldCount = previousDict[recipeId]?.items.count ?? 0 + let newCount = self.remindersStore.groceryDict[recipeId]?.items.count ?? 0 + if oldCount != newCount { + self.syncManager?.scheduleSync(forRecipeId: recipeId) + } + } + self.recentlyModifiedByUs.removeAll() } } } + func configureSyncManager(appState: AppState) { + syncManager = GroceryStateSyncManager(appState: appState, groceryManager: self) + } + // MARK: - Grocery Operations func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) { @@ -36,9 +59,11 @@ class GroceryListManager: ObservableObject { localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) groceryDict = localStore.groceryDict case .appleReminders: + recentlyModifiedByUs.insert(recipeId) remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) groceryDict = remindersStore.groceryDict } + syncManager?.scheduleSync(forRecipeId: recipeId) } func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) { @@ -47,9 +72,11 @@ class GroceryListManager: ObservableObject { localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName) groceryDict = localStore.groceryDict case .appleReminders: + recentlyModifiedByUs.insert(recipeId) remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName) groceryDict = remindersStore.groceryDict } + syncManager?.scheduleSync(forRecipeId: recipeId) } func deleteItem(_ itemName: String, fromRecipe recipeId: String) { @@ -58,9 +85,10 @@ class GroceryListManager: ObservableObject { localStore.deleteItem(itemName, fromRecipe: recipeId) groceryDict = localStore.groceryDict case .appleReminders: + recentlyModifiedByUs.insert(recipeId) remindersStore.deleteItem(itemName, fromRecipe: recipeId) - // Cache update happens async in RemindersGroceryStore via onDataChanged } + syncManager?.scheduleSync(forRecipeId: recipeId) } func deleteGroceryRecipe(_ recipeId: String) { @@ -69,18 +97,25 @@ class GroceryListManager: ObservableObject { localStore.deleteGroceryRecipe(recipeId) groceryDict = localStore.groceryDict case .appleReminders: + recentlyModifiedByUs.insert(recipeId) remindersStore.deleteGroceryRecipe(recipeId) } + syncManager?.scheduleSync(forRecipeId: recipeId) } func deleteAll() { + let recipeIds = Array(groceryDict.keys) switch mode { case .inApp: localStore.deleteAll() groceryDict = localStore.groceryDict case .appleReminders: + recentlyModifiedByUs.formUnion(recipeIds) remindersStore.deleteAll() } + for recipeId in recipeIds { + syncManager?.scheduleSync(forRecipeId: recipeId) + } } func toggleItemChecked(_ groceryItem: GroceryRecipeItem) { diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift b/Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift new file mode 100644 index 0000000..30c6885 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift @@ -0,0 +1,58 @@ +// +// GroceryStateModels.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation + +/// Tracks grocery list state for a recipe, stored as `_groceryState` in the recipe JSON on the server. +struct GroceryState: Codable { + var version: Int = 1 + var lastModified: String + var items: [String: GroceryItemState] + + init(lastModified: String = GroceryStateDate.now(), items: [String: GroceryItemState] = [:]) { + self.version = 1 + self.lastModified = lastModified + self.items = items + } +} + +struct GroceryItemState: Codable { + enum Status: String, Codable { + case added + case completed + case removed + } + + var status: Status + var addedAt: String + var modifiedAt: String + + init(status: Status, addedAt: String = GroceryStateDate.now(), modifiedAt: String = GroceryStateDate.now()) { + self.status = status + self.addedAt = addedAt + self.modifiedAt = modifiedAt + } +} + +/// ISO 8601 date helpers. Dates are stored as strings to avoid coupling to a parent encoder's date strategy. +enum GroceryStateDate { + private static let formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + static func now() -> String { + formatter.string(from: Date()) + } + + static func date(from string: String) -> Date? { + formatter.date(from: string) + } + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift b/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift new file mode 100644 index 0000000..682e3ce --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift @@ -0,0 +1,168 @@ +// +// GroceryStateSyncManager.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation +import OSLog + +@MainActor +class GroceryStateSyncManager { + private weak var appState: AppState? + private weak var groceryManager: GroceryListManager? + + private var debounceTimers: [String: Task] = [:] + private let debounceInterval: TimeInterval = 2.0 + + init(appState: AppState, groceryManager: GroceryListManager) { + self.appState = appState + self.groceryManager = groceryManager + } + + // MARK: - Push Flow + + /// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe. + func scheduleSync(forRecipeId recipeId: String) { + guard UserSettings.shared.grocerySyncEnabled 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?.pushGroceryState(forRecipeId: recipeId) + } + } + + /// Builds local grocery state, fetches server recipe, merges, and PUTs back. + func pushGroceryState(forRecipeId recipeId: String) async { + guard let appState, let groceryManager else { return } + guard let recipeIdInt = Int(recipeId) else { return } + + // Build local state from current grocery data + let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager) + + // Fetch latest recipe from server + guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else { + Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server") + return + } + + // Merge local state with server state + let serverState = serverRecipe.groceryState + let merged = mergeStates(local: localState, server: serverState) + + // Upload merged state + var updatedRecipe = serverRecipe + updatedRecipe.groceryState = merged + let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false) + if let alert { + Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))") + } + } + + // MARK: - Pull Flow + + /// Reconciles server grocery state with local grocery data. Called when a recipe is loaded. + func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) { + guard let groceryManager else { return } + guard let serverState, !serverState.items.isEmpty else { return } + + let localItems = Set( + groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? [] + ) + + for (itemName, itemState) in serverState.items { + switch itemState.status { + case .added: + if !localItems.contains(itemName) { + groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) + } + case .removed: + if localItems.contains(itemName) { + groceryManager.deleteItem(itemName, fromRecipe: recipeId) + } + case .completed: + // Don't re-add completed items; leave local state as-is + break + } + } + } + + // MARK: - Initial Sync + + /// Pushes any local-only items and reconciles server items on app launch. + func performInitialSync() async { + guard let appState, let groceryManager else { return } + + let recipeIds = Array(groceryManager.groceryDict.keys) + for recipeId in recipeIds { + guard let recipeIdInt = Int(recipeId) else { continue } + + // Push local state to server + await pushGroceryState(forRecipeId: recipeId) + + // Fetch back and reconcile + if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) { + let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name + reconcileFromServer( + serverState: serverRecipe.groceryState, + recipeId: recipeId, + recipeName: recipeName + ) + } + } + } + + // MARK: - Merge Logic + + /// Merges local and server states using per-item last-writer-wins on `modifiedAt`. + private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState { + guard let server else { return local } + + var merged = local.items + for (itemName, serverItem) in server.items { + if let localItem = merged[itemName] { + // Both have the item — keep the one with the later modifiedAt + let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast + let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast + if serverDate > localDate { + merged[itemName] = serverItem + } + } else { + // Only server has this item — keep it + merged[itemName] = serverItem + } + } + + // Garbage collection: remove items that are removed/completed and older than 30 days + let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) + merged = merged.filter { _, item in + if item.status == .added { return true } + guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true } + return modDate > thirtyDaysAgo + } + + return GroceryState( + lastModified: GroceryStateDate.now(), + items: merged + ) + } + + // MARK: - Build Local State + + /// Builds a `GroceryState` from the current local grocery data for a recipe. + private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager) -> GroceryState { + guard let groceryRecipe = groceryManager.groceryDict[recipeId] else { + return GroceryState() + } + + var items: [String: GroceryItemState] = [:] + let now = GroceryStateDate.now() + for item in groceryRecipe.items { + let status: GroceryItemState.Status = item.isChecked ? .completed : .added + items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now) + } + + return GroceryState(lastModified: now, items: items) + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index e74128b..a4d2de6 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -26,7 +26,8 @@ class ObservableRecipeDetail: ObservableObject { @Published var recipeIngredient: [String] @Published var recipeInstructions: [String] @Published var nutrition: [String:String] - + var groceryState: GroceryState? + // Additional functionality @Published var ingredientMultiplier: Double @@ -48,7 +49,8 @@ class ObservableRecipeDetail: ObservableObject { recipeIngredient = [] recipeInstructions = [] nutrition = [:] - + groceryState = nil + ingredientMultiplier = 1 } @@ -68,7 +70,8 @@ class ObservableRecipeDetail: ObservableObject { recipeIngredient = recipeDetail.recipeIngredient recipeInstructions = recipeDetail.recipeInstructions nutrition = recipeDetail.nutrition - + groceryState = recipeDetail.groceryState + ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) } @@ -90,7 +93,8 @@ class ObservableRecipeDetail: ObservableObject { tool: self.tool, recipeIngredient: self.recipeIngredient, recipeInstructions: self.recipeInstructions, - nutrition: self.nutrition + nutrition: self.nutrition, + groceryState: self.groceryState ) } diff --git a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift index 081ebb8..f46bb94 100644 --- a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift @@ -50,8 +50,9 @@ struct RecipeDetail: Codable { var recipeIngredient: [String] var recipeInstructions: [String] var nutrition: [String:String] - - 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]) { + var groceryState: GroceryState? + + 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) { self.name = name self.keywords = keywords self.dateCreated = dateCreated @@ -69,8 +70,9 @@ struct RecipeDetail: Codable { self.recipeIngredient = recipeIngredient self.recipeInstructions = recipeInstructions self.nutrition = nutrition + self.groceryState = groceryState } - + init() { name = "" keywords = "" @@ -89,11 +91,13 @@ struct RecipeDetail: Codable { recipeIngredient = [] recipeInstructions = [] nutrition = [:] + groceryState = 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" } init(from decoder: Decoder) throws { @@ -132,6 +136,8 @@ struct RecipeDetail: Codable { } else { nutrition = [:] } + + groceryState = try? container.decode(GroceryState.self, forKey: .groceryState) } func encode(to encoder: Encoder) throws { @@ -154,6 +160,7 @@ struct RecipeDetail: Codable { try container.encode(recipeIngredient, forKey: .recipeIngredient) try container.encode(recipeInstructions, forKey: .recipeInstructions) try container.encode(nutrition, forKey: .nutrition) + try container.encodeIfPresent(groceryState, forKey: .groceryState) } } diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index cd41719..ed3cd69 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -132,6 +132,12 @@ class UserSettings: ObservableObject { UserDefaults.standard.set(remindersListIdentifier, forKey: "remindersListIdentifier") } } + + @Published var grocerySyncEnabled: Bool { + didSet { + UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled") + } + } init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" @@ -154,6 +160,7 @@ class UserSettings: ObservableObject { self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "." 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 if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 3b7adc0..0edffef 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -2023,6 +2023,9 @@ } } } + }, + "Grocery list state is synced via your Nextcloud server by storing it alongside recipe data." : { + }, "Grocery list storage" : { "localizations" : { @@ -4319,6 +4322,9 @@ } } } + }, + "Sync grocery list across devices" : { + }, "Thank you for downloading" : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index a067469..d0ce254 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -81,6 +81,10 @@ struct MainView: View { } } await groceryList.load() + groceryList.configureSyncManager(appState: appState) + if UserSettings.shared.grocerySyncEnabled { + await groceryList.syncManager?.performInitialSync() + } recipeViewModel.presentLoadingIndicator = false } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index cd79b73..be6f393 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -12,6 +12,7 @@ import SwiftUI struct RecipeView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var groceryList: GroceryListManager @Environment(\.dismiss) private var dismiss @StateObject var viewModel: ViewModel @GestureState private var dragOffset = CGSize.zero @@ -75,6 +76,15 @@ struct RecipeView: View { fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer ) + // Reconcile server grocery state with local data + if UserSettings.shared.grocerySyncEnabled { + groceryList.syncManager?.reconcileFromServer( + serverState: viewModel.recipeDetail.groceryState, + recipeId: String(viewModel.recipe.recipe_id), + recipeName: viewModel.recipeDetail.name + ) + } + } else { // Prepare view for a new recipe if let preloaded = viewModel.preloadedRecipeDetail { diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 6af654a..6067d5d 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -93,10 +93,16 @@ struct SettingsView: View { } } } + + Toggle(isOn: $userSettings.grocerySyncEnabled) { + Text("Sync grocery list across devices") + } } header: { Text("Grocery List") } footer: { - if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue { + if userSettings.grocerySyncEnabled { + Text("Grocery list state is synced via your Nextcloud server by storing it alongside recipe data.") + } else if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue { Text("Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app.") } else { Text("Grocery items are stored locally on this device.")