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>
174 lines
5.5 KiB
Swift
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()
|
|
}
|
|
}
|