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>
346 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|