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:
326
Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift
Normal file
326
Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user