Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift
Hendrik Hogertz 5890dbcad4 Add cross-device grocery list sync via Nextcloud Cookbook API
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>
2026-02-15 04:14:02 +01:00

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()
}
}