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:
144
Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift
Normal file
144
Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// GroceryListManager.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class GroceryListManager: ObservableObject {
|
||||
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
||||
|
||||
let localStore = GroceryList()
|
||||
let remindersStore = RemindersGroceryStore()
|
||||
|
||||
private var mode: GroceryListMode {
|
||||
GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp
|
||||
}
|
||||
|
||||
init() {
|
||||
remindersStore.onDataChanged = { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.mode == .appleReminders {
|
||||
self.groceryDict = self.remindersStore.groceryDict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grocery Operations
|
||||
|
||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
||||
groceryDict = remindersStore.groceryDict
|
||||
}
|
||||
}
|
||||
|
||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
||||
groceryDict = remindersStore.groceryDict
|
||||
}
|
||||
}
|
||||
|
||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
remindersStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||
// Cache update happens async in RemindersGroceryStore via onDataChanged
|
||||
}
|
||||
}
|
||||
|
||||
func deleteGroceryRecipe(_ recipeId: String) {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.deleteGroceryRecipe(recipeId)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
remindersStore.deleteGroceryRecipe(recipeId)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAll() {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.deleteAll()
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
remindersStore.deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
localStore.toggleItemChecked(groceryItem)
|
||||
case .appleReminders:
|
||||
// Reminders don't support checked state in our model
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func containsItem(at recipeId: String, item: String) -> Bool {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
return localStore.containsItem(at: recipeId, item: item)
|
||||
case .appleReminders:
|
||||
return remindersStore.containsItem(at: recipeId, item: item)
|
||||
}
|
||||
}
|
||||
|
||||
func containsRecipe(_ recipeId: String) -> Bool {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
return localStore.containsRecipe(recipeId)
|
||||
case .appleReminders:
|
||||
return remindersStore.containsRecipe(recipeId)
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
switch mode {
|
||||
case .inApp:
|
||||
await localStore.load()
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
await remindersStore.load()
|
||||
groceryDict = remindersStore.groceryDict
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
if mode == .inApp {
|
||||
localStore.save()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminders Helpers (for Settings UI)
|
||||
|
||||
var remindersPermissionStatus: EKAuthorizationStatus {
|
||||
remindersStore.checkPermissionStatus()
|
||||
}
|
||||
|
||||
func requestRemindersAccess() async -> Bool {
|
||||
await remindersStore.requestAccess()
|
||||
}
|
||||
|
||||
func availableReminderLists() -> [EKCalendar] {
|
||||
remindersStore.availableReminderLists()
|
||||
}
|
||||
}
|
||||
20
Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift
Normal file
20
Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// GroceryListMode.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum GroceryListMode: String, CaseIterable {
|
||||
case inApp = "inApp"
|
||||
case appleReminders = "appleReminders"
|
||||
|
||||
func descriptor() -> String {
|
||||
switch self {
|
||||
case .inApp: return String(localized: "In-App")
|
||||
case .appleReminders: return String(localized: "Apple Reminders")
|
||||
}
|
||||
}
|
||||
|
||||
static let allValues: [GroceryListMode] = GroceryListMode.allCases
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,18 @@ class UserSettings: ObservableObject {
|
||||
UserDefaults.standard.set(decimalNumberSeparator, forKey: "decimalNumberSeparator")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var groceryListMode: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(groceryListMode, forKey: "groceryListMode")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var remindersListIdentifier: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(remindersListIdentifier, forKey: "remindersListIdentifier")
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||
@@ -140,6 +152,8 @@ class UserSettings: ObservableObject {
|
||||
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
||||
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
||||
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
|
||||
self.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
|
||||
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
|
||||
|
||||
if authString == "" {
|
||||
if token != "" && username != "" {
|
||||
|
||||
Reference in New Issue
Block a user