Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Hendrik Hogertz c38d4075be Fix grocery sync deletions not persisting and Reminders race condition
Stop cascading syncs by adding an isReconciling flag so that
reconcileFromServer no longer triggers scheduleSync via addItem/deleteItem.
Make Reminders write-only by removing the diff/sync logic from the
onDataChanged callback. Fetch fresh server state in RecipeView reconcile
instead of using stale local cache. Track pending removal recipe IDs via
DataStore so performInitialSync can push deletions for recipes whose
grocery keys have already been removed from groceryDict.

Fix a race condition in RemindersGroceryStore where EKEventStoreChanged
notifications triggered load() before saveMappings() finished writing to
disk, causing the correct in-memory state to be overwritten with stale
data. Add ignoreNextExternalChange flag to skip self-triggered reloads.

Restyle the add/remove all grocery button to match the Plan recipe button
style using Label, subheadline font, and rounded rectangle background.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 06:04:41 +01:00

234 lines
8.9 KiB
Swift

//
// 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
private var isReconciling = false
private let dataStore = DataStore()
private let pendingRemovalPath = "grocery_pending_removals.data"
private(set) var pendingRemovalRecipeIds: Set<String> = []
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 }
guard !isReconciling 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 }
// Fetch latest recipe from server first so we can detect deletions
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
return
}
let serverState = serverRecipe.groceryState
// Build local state, passing server state so deleted items can be marked .removed
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState)
// Merge local state with server state
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 }
isReconciling = true
defer { isReconciling = false }
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 }
await loadPendingRemovals()
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
)
}
}
// Push deletion state for recipes whose items were fully removed
for recipeId in pendingRemovalRecipeIds {
guard !recipeIds.contains(recipeId) else {
// Recipe was re-added locally since removal was tracked; clear it
pendingRemovalRecipeIds.remove(recipeId)
continue
}
await pushGroceryState(forRecipeId: recipeId)
pendingRemovalRecipeIds.remove(recipeId)
}
savePendingRemovals()
}
// 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.
/// When `serverState` is provided, any server item with `.added` status that is
/// absent locally is emitted as `.removed` so the deletion propagates to the server.
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState {
var items: [String: GroceryItemState] = [:]
let now = GroceryStateDate.now()
// Existing local items
if let groceryRecipe = groceryManager.groceryDict[recipeId] {
for item in groceryRecipe.items {
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
}
}
// Mark items that exist on server as .added but are absent locally as .removed
if let serverState {
for (itemName, serverItem) in serverState.items {
if items[itemName] == nil && serverItem.status == .added {
items[itemName] = GroceryItemState(
status: .removed,
addedAt: serverItem.addedAt,
modifiedAt: now
)
}
}
}
return GroceryState(lastModified: now, items: items)
}
// MARK: - Pending Removal Tracking
/// Records a recipe ID whose grocery items were fully removed, so that
/// `performInitialSync` can push the deletion even after the key disappears
/// from `groceryDict`.
func trackPendingRemoval(recipeId: String) {
pendingRemovalRecipeIds.insert(recipeId)
savePendingRemovals()
}
func clearPendingRemoval(recipeId: String) {
guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return }
savePendingRemovals()
}
private func loadPendingRemovals() async {
guard let loaded: Set<String> = try? await dataStore.load(fromPath: pendingRemovalPath) else { return }
pendingRemovalRecipeIds = loaded
}
private func savePendingRemovals() {
Task {
await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath)
}
}
}