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>
180 lines
5.9 KiB
Swift
180 lines
5.9 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 {
|
|
let previousDict = self.groceryDict
|
|
self.groceryDict = self.remindersStore.groceryDict
|
|
|
|
// Only sync recipes that changed externally (e.g. checked off in Reminders app),
|
|
// not ones we just modified ourselves.
|
|
for recipeId in self.remindersStore.groceryDict.keys {
|
|
guard !self.recentlyModifiedByUs.contains(recipeId) else { continue }
|
|
// Detect if item count changed (external add/remove/complete)
|
|
let oldCount = previousDict[recipeId]?.items.count ?? 0
|
|
let newCount = self.remindersStore.groceryDict[recipeId]?.items.count ?? 0
|
|
if oldCount != newCount {
|
|
self.syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
}
|
|
}
|
|
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?.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?.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)
|
|
}
|
|
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?.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?.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()
|
|
}
|
|
}
|