Add Apple Reminders integration for grocery list with local mapping persistence

Introduce a GroceryListManager facade that delegates to either the existing
in-app GroceryList or a new RemindersGroceryStore backed by EventKit. Users
choose the mode in Settings; when Reminders mode is active the Grocery List
tab is hidden. Recipe-to-reminder grouping uses a local mapping file
(reminder_mappings.data) instead of polluting the reminder's notes field,
with automatic pruning when reminders are deleted externally.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 02:54:52 +01:00
parent 6824dbea6b
commit 98c82dc537
15 changed files with 800 additions and 12 deletions

View File

@@ -0,0 +1,326 @@
//
// 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()
init() {
NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged,
object: eventStore,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self else { 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 {
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 {
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 {
try eventStore.commit()
} catch {
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 {
try self.eventStore.remove(reminder, commit: true)
} catch {
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 {
try self.eventStore.commit()
} catch {
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 {
try self.eventStore.commit()
} catch {
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)
}
}
}