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>
327 lines
12 KiB
Swift
327 lines
12 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()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|