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

346 lines
13 KiB
Swift

//
// RemindersGroceryStore.swift
// Nextcloud Cookbook iOS Client
//
import EventKit
import Foundation
import OSLog
/// Maps a reminder's calendarItemIdentifier to its recipe context.
struct ReminderMapping: Codable {
let reminderIdentifier: String
let recipeId: String
let recipeName: String
let itemName: String
}
/// Persisted mapping file: keyed by recipeId, each holding an array of reminder mappings.
struct ReminderMappingStore: Codable {
var recipes: [String: RecipeMappingEntry] = [:]
}
struct RecipeMappingEntry: Codable {
let recipeName: String
var mappings: [ReminderMapping]
}
@MainActor
class RemindersGroceryStore {
private let eventStore = EKEventStore()
private(set) var groceryDict: [String: GroceryRecipe] = [:]
var onDataChanged: (() -> Void)?
private let dataStore = DataStore()
private let mappingPath = "reminder_mappings.data"
private var mappingStore = ReminderMappingStore()
/// When true, the next `EKEventStoreChanged` notification is skipped because
/// it was triggered by our own save. Prevents a race where `load()` reads stale
/// mapping data from disk before `saveMappings()` finishes writing.
private var ignoreNextExternalChange = false
init() {
NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged,
object: eventStore,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
if self.ignoreNextExternalChange {
self.ignoreNextExternalChange = false
return
}
await self.load()
self.onDataChanged?()
}
}
}
deinit {
NotificationCenter.default.removeObserver(self, name: .EKEventStoreChanged, object: eventStore)
}
// MARK: - Permission
func checkPermissionStatus() -> EKAuthorizationStatus {
EKEventStore.authorizationStatus(for: .reminder)
}
func requestAccess() async -> Bool {
do {
return try await eventStore.requestFullAccessToReminders()
} catch {
Logger.view.error("Failed to request Reminders access: \(error.localizedDescription)")
return false
}
}
// MARK: - Lists
func availableReminderLists() -> [EKCalendar] {
eventStore.calendars(for: .reminder)
}
private func targetCalendar() -> EKCalendar? {
let identifier = UserSettings.shared.remindersListIdentifier
if !identifier.isEmpty,
let calendar = eventStore.calendar(withIdentifier: identifier) {
return calendar
}
return eventStore.defaultCalendarForNewReminders()
}
// MARK: - Fetch Helper
private nonisolated func fetchReminders(matching predicate: NSPredicate, in store: EKEventStore) async -> [EKReminder] {
await withCheckedContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
}
}
// MARK: - CRUD
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
guard let calendar = targetCalendar() else {
Logger.view.error("No target Reminders calendar available")
return
}
let reminder = EKReminder(eventStore: eventStore)
reminder.title = itemName
reminder.calendar = calendar
do {
ignoreNextExternalChange = true
try eventStore.save(reminder, commit: true)
let name = recipeName ?? "-"
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: itemName)
appendToCache(itemName: itemName, recipeId: recipeId, recipeName: name)
} catch {
ignoreNextExternalChange = false
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
}
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
guard let calendar = targetCalendar() else {
Logger.view.error("No target Reminders calendar available")
return
}
let name = recipeName ?? "-"
for item in items {
let reminder = EKReminder(eventStore: eventStore)
reminder.title = item
reminder.calendar = calendar
do {
try eventStore.save(reminder, commit: false)
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: item)
appendToCache(itemName: item, recipeId: recipeId, recipeName: name)
} catch {
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
}
}
do {
ignoreNextExternalChange = true
try eventStore.commit()
} catch {
ignoreNextExternalChange = false
Logger.view.error("Failed to commit reminders: \(error.localizedDescription)")
}
saveMappings()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
// Find the reminder identifier from our mapping
guard let entry = mappingStore.recipes[recipeId],
let mapping = entry.mappings.first(where: { $0.itemName == itemName }) else { return }
let identifier = mapping.reminderIdentifier
// Find and remove the actual reminder
guard let calendar = targetCalendar() else { return }
let predicate = eventStore.predicateForReminders(in: [calendar])
let store = eventStore
Task {
let reminders = await fetchReminders(matching: predicate, in: store)
for reminder in reminders where reminder.calendarItemIdentifier == identifier {
do {
self.ignoreNextExternalChange = true
try self.eventStore.remove(reminder, commit: true)
} catch {
self.ignoreNextExternalChange = false
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
}
break
}
self.removeMapping(reminderIdentifier: identifier, recipeId: recipeId)
self.removeFromCache(itemName: itemName, recipeId: recipeId)
}
}
func deleteGroceryRecipe(_ recipeId: String) {
guard let entry = mappingStore.recipes[recipeId] else { return }
let identifiers = Set(entry.mappings.map(\.reminderIdentifier))
guard let calendar = targetCalendar() else { return }
let predicate = eventStore.predicateForReminders(in: [calendar])
let store = eventStore
Task {
let reminders = await fetchReminders(matching: predicate, in: store)
for reminder in reminders where identifiers.contains(reminder.calendarItemIdentifier) {
do {
try self.eventStore.remove(reminder, commit: false)
} catch {
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
}
}
do {
self.ignoreNextExternalChange = true
try self.eventStore.commit()
} catch {
self.ignoreNextExternalChange = false
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
}
self.mappingStore.recipes.removeValue(forKey: recipeId)
self.saveMappings()
self.groceryDict.removeValue(forKey: recipeId)
self.onDataChanged?()
}
}
func deleteAll() {
let allIdentifiers = Set(mappingStore.recipes.values.flatMap { $0.mappings.map(\.reminderIdentifier) })
guard !allIdentifiers.isEmpty else { return }
guard let calendar = targetCalendar() else { return }
let predicate = eventStore.predicateForReminders(in: [calendar])
let store = eventStore
Task {
let reminders = await fetchReminders(matching: predicate, in: store)
for reminder in reminders where allIdentifiers.contains(reminder.calendarItemIdentifier) {
do {
try self.eventStore.remove(reminder, commit: false)
} catch {
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
}
}
do {
self.ignoreNextExternalChange = true
try self.eventStore.commit()
} catch {
self.ignoreNextExternalChange = false
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
}
self.mappingStore.recipes = [:]
self.saveMappings()
self.groceryDict = [:]
self.onDataChanged?()
}
}
func containsItem(at recipeId: String, item: String) -> Bool {
guard let recipe = groceryDict[recipeId] else { return false }
return recipe.items.contains(where: { $0.name == item })
}
func containsRecipe(_ recipeId: String) -> Bool {
groceryDict[recipeId] != nil
}
// MARK: - Load / Sync
func load() async {
// Load the local mapping first
if let stored: ReminderMappingStore = try? await dataStore.load(fromPath: mappingPath) {
mappingStore = stored
}
guard checkPermissionStatus() == .fullAccess else { return }
guard let calendar = targetCalendar() else { return }
let predicate = eventStore.predicateForReminders(in: [calendar])
let store = eventStore
let reminders = await fetchReminders(matching: predicate, in: store)
// Build a set of live reminder identifiers for cleanup
let liveIdentifiers = Set(reminders.map(\.calendarItemIdentifier))
// Prune mappings for reminders that no longer exist (deleted externally)
var pruned = false
for (recipeId, entry) in mappingStore.recipes {
let before = entry.mappings.count
let filtered = entry.mappings.filter { liveIdentifiers.contains($0.reminderIdentifier) }
if filtered.isEmpty {
mappingStore.recipes.removeValue(forKey: recipeId)
pruned = true
} else if filtered.count != before {
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: entry.recipeName, mappings: filtered)
pruned = true
}
}
if pruned {
saveMappings()
}
// Rebuild groceryDict from mappings (source of truth for grouping)
var dict: [String: GroceryRecipe] = [:]
for (recipeId, entry) in mappingStore.recipes {
let items = entry.mappings.map { GroceryRecipeItem($0.itemName) }
dict[recipeId] = GroceryRecipe(name: entry.recipeName, items: items)
}
groceryDict = dict
}
// MARK: - Mapping Persistence
private func addMapping(reminderIdentifier: String, recipeId: String, recipeName: String, itemName: String) {
let mapping = ReminderMapping(
reminderIdentifier: reminderIdentifier,
recipeId: recipeId,
recipeName: recipeName,
itemName: itemName
)
if mappingStore.recipes[recipeId] != nil {
mappingStore.recipes[recipeId]?.mappings.append(mapping)
} else {
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: recipeName, mappings: [mapping])
}
saveMappings()
}
private func removeMapping(reminderIdentifier: String, recipeId: String) {
guard var entry = mappingStore.recipes[recipeId] else { return }
entry.mappings.removeAll { $0.reminderIdentifier == reminderIdentifier }
if entry.mappings.isEmpty {
mappingStore.recipes.removeValue(forKey: recipeId)
} else {
mappingStore.recipes[recipeId] = entry
}
saveMappings()
}
private func saveMappings() {
Task {
await dataStore.save(data: mappingStore, toPath: mappingPath)
}
}
// MARK: - Cache Helpers
private func appendToCache(itemName: String, recipeId: String, recipeName: String) {
if groceryDict[recipeId] != nil {
groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
groceryDict[recipeId] = GroceryRecipe(name: recipeName, item: GroceryRecipeItem(itemName))
}
}
private func removeFromCache(itemName: String, recipeId: String) {
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex)
if groceryDict[recipeId]?.items.isEmpty == true {
groceryDict.removeValue(forKey: recipeId)
}
}
}