Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/GroceryListManager.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

174 lines
5.5 KiB
Swift

//
// GroceryListManager.swift
// Nextcloud Cookbook iOS Client
//
import EventKit
import Foundation
import OSLog
import SwiftUI
@MainActor
class GroceryListManager: ObservableObject {
@Published var groceryDict: [String: GroceryRecipe] = [:]
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
}
init() {
remindersStore.onDataChanged = { [weak self] in
guard let self else { return }
if self.mode == .appleReminders {
self.groceryDict = self.remindersStore.groceryDict
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) {
switch mode {
case .inApp:
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?.clearPendingRemoval(recipeId: recipeId)
syncManager?.scheduleSync(forRecipeId: recipeId)
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
switch mode {
case .inApp:
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?.clearPendingRemoval(recipeId: recipeId)
syncManager?.scheduleSync(forRecipeId: recipeId)
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
switch mode {
case .inApp:
localStore.deleteItem(itemName, fromRecipe: recipeId)
groceryDict = localStore.groceryDict
case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.deleteItem(itemName, fromRecipe: recipeId)
}
if groceryDict[recipeId] == nil {
syncManager?.trackPendingRemoval(recipeId: recipeId)
}
syncManager?.scheduleSync(forRecipeId: recipeId)
}
func deleteGroceryRecipe(_ recipeId: String) {
switch mode {
case .inApp:
localStore.deleteGroceryRecipe(recipeId)
groceryDict = localStore.groceryDict
case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.deleteGroceryRecipe(recipeId)
}
syncManager?.trackPendingRemoval(recipeId: 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?.trackPendingRemoval(recipeId: recipeId)
syncManager?.scheduleSync(forRecipeId: recipeId)
}
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
switch mode {
case .inApp:
localStore.toggleItemChecked(groceryItem)
case .appleReminders:
// Reminders don't support checked state in our model
break
}
}
func containsItem(at recipeId: String, item: String) -> Bool {
switch mode {
case .inApp:
return localStore.containsItem(at: recipeId, item: item)
case .appleReminders:
return remindersStore.containsItem(at: recipeId, item: item)
}
}
func containsRecipe(_ recipeId: String) -> Bool {
switch mode {
case .inApp:
return localStore.containsRecipe(recipeId)
case .appleReminders:
return remindersStore.containsRecipe(recipeId)
}
}
func load() async {
switch mode {
case .inApp:
await localStore.load()
groceryDict = localStore.groceryDict
case .appleReminders:
await remindersStore.load()
groceryDict = remindersStore.groceryDict
}
}
func save() {
if mode == .inApp {
localStore.save()
}
}
// MARK: - Reminders Helpers (for Settings UI)
var remindersPermissionStatus: EKAuthorizationStatus {
remindersStore.checkPermissionStatus()
}
func requestRemindersAccess() async -> Bool {
await remindersStore.requestAccess()
}
func availableReminderLists() -> [EKCalendar] {
remindersStore.availableReminderLists()
}
}