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

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