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>
234 lines
8.9 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|