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 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 04:14:02 +01:00
parent 501434bd0e
commit 5890dbcad4
11 changed files with 323 additions and 10 deletions

View File

@@ -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 = "<group>"; };
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.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>"; };
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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;
};

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ 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,6 +49,7 @@ class ObservableRecipeDetail: ObservableObject {
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
groceryState = nil
ingredientMultiplier = 1
}
@@ -68,6 +70,7 @@ 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
)
}

View File

@@ -50,8 +50,9 @@ struct RecipeDetail: Codable {
var recipeIngredient: [String]
var recipeInstructions: [String]
var 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]) {
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,6 +70,7 @@ struct RecipeDetail: Codable {
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
self.groceryState = groceryState
}
init() {
@@ -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)
}
}

View File

@@ -133,6 +133,12 @@ class UserSettings: ObservableObject {
}
}
@Published var grocerySyncEnabled: Bool {
didSet {
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
}
}
init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") 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 != "" {

View File

@@ -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" : {

View File

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

View File

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

View File

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