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:
168
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal file
168
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user