diff --git a/CLAUDE.md b/CLAUDE.md index a5b5dce..fb453de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \ test ``` -- **Deployment target**: iOS 16.4 +- **Deployment target**: iOS 18 - **Swift version**: 5.0 - **Targets**: iPhone and iPad, Mac Catalyst enabled diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 61b9957..5549ecc 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -68,6 +68,9 @@ A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; }; A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; }; A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; + D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; }; + D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; }; + D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -153,6 +156,9 @@ A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; }; A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = ""; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = ""; }; + D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = ""; }; + D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = ""; }; + D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -290,6 +296,9 @@ A70171C52AB4C43A00064C43 /* DataModels.swift */, A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */, + D1A0CE002D0A000100000001 /* GroceryListMode.swift */, + D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */, + D1A0CE042D0A000300000003 /* GroceryListManager.swift */, ); path = Data; sourceTree = ""; @@ -623,6 +632,9 @@ A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, + D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */, + D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */, + D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift b/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift new file mode 100644 index 0000000..98e6af3 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift @@ -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() + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift b/Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift new file mode 100644 index 0000000..07b36d9 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift @@ -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 +} diff --git a/Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift b/Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift new file mode 100644 index 0000000..1827679 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift @@ -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) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index cddb099..cd41719 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -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 != "" { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 23ea179..1fd0f8d 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -4523,6 +4523,204 @@ } } } + }, + "In-App" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In der App" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En la app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans l'app" + } + } + } + }, + "Apple Reminders" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Erinnerungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recordatorios de Apple" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rappels Apple" + } + } + } + }, + "Grocery list storage" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einkaufsliste Speicherort" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenamiento de la lista de compras" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stockage de la liste de courses" + } + } + } + }, + "Reminders list" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erinnerungsliste" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de recordatorios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liste de rappels" + } + } + } + }, + "Grant Reminders Access" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff auf Erinnerungen erlauben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir acceso a Recordatorios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser l'accès aux Rappels" + } + } + } + }, + "Open Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen öffnen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir Ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les Réglages" + } + } + } + }, + "Reminders access was denied. Please enable it in System Settings to use this feature." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Zugriff auf Erinnerungen wurde verweigert. Bitte aktiviere ihn in den Systemeinstellungen, um diese Funktion zu nutzen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se denegó el acceso a Recordatorios. Actívalo en los Ajustes del sistema para usar esta función." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'accès aux Rappels a été refusé. Veuillez l'activer dans les Réglages système pour utiliser cette fonctionnalité." + } + } + } + }, + "Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einkaufsartikel werden in Apple Erinnerungen gespeichert. Der Einkaufslisten-Tab wird ausgeblendet, da du die Artikel direkt in der Erinnerungen-App verwalten kannst." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los artículos de la lista se guardarán en Recordatorios de Apple. La pestaña de lista de compras se ocultará, ya que puedes gestionar los artículos directamente en la app Recordatorios." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les articles seront enregistrés dans Rappels Apple. L'onglet Liste de courses sera masqué car vous pouvez gérer les articles directement dans l'app Rappels." + } + } + } + }, + "Grocery items are stored locally on this device." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einkaufsartikel werden lokal auf diesem Gerät gespeichert." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los artículos de la lista se almacenan localmente en este dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les articles sont stockés localement sur cet appareil." + } + } + } } }, "version" : "1.1" diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index a880736..a067469 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -9,12 +9,14 @@ import SwiftUI struct MainView: View { @StateObject var appState = AppState() - @StateObject var groceryList = GroceryList() + @StateObject var groceryList = GroceryListManager() // Tab ViewModels @StateObject var recipeViewModel = RecipeTabView.ViewModel() @StateObject var searchViewModel = SearchTabView.ViewModel() + @ObservedObject private var userSettings = UserSettings.shared + @State private var selectedTab: Tab = .recipes enum Tab { @@ -37,13 +39,23 @@ struct MainView: View { .environmentObject(groceryList) } - SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) { - GroceryListTabView() - .environmentObject(groceryList) + if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue { + SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) { + GroceryListTabView() + .environmentObject(groceryList) + } } } .tabViewStyle(.sidebarAdaptable) .modifier(TabBarMinimizeModifier()) + .onChange(of: userSettings.groceryListMode) { _, newValue in + if newValue == GroceryListMode.appleReminders.rawValue && selectedTab == .groceryList { + selectedTab = .recipes + } + Task { + await groceryList.load() + } + } .task { recipeViewModel.presentLoadingIndicator = true await appState.getCategories() diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift index 753da5d..1edd0fa 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift @@ -7,7 +7,7 @@ import SwiftUI struct AllRecipesListView: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager @Binding var showEditView: Bool @State private var allRecipes: [Recipe] = [] @State private var searchText: String = "" diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 29af8a8..91248ea 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -12,7 +12,7 @@ import SwiftUI struct RecipeListView: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager @State var categoryName: String @State var searchText: String = "" @Binding var showEditView: Bool diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index 68f1d85..2a2f745 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -11,7 +11,7 @@ import SwiftUI // MARK: - RecipeView Ingredients Section struct RecipeIngredientSection: View { - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager @ObservedObject var viewModel: RecipeView.ViewModel var body: some View { @@ -86,7 +86,7 @@ struct RecipeIngredientSection: View { // MARK: - RecipeIngredientSection List Item fileprivate struct IngredientListItem: View { - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager @Binding var ingredient: String @Binding var servings: Double @State var recipeYield: Double diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 8c688b6..6af654a 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -5,6 +5,7 @@ // Created by Vincent Meilinger on 15.09.23. // +import EventKit import Foundation import OSLog import SwiftUI @@ -13,8 +14,11 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var groceryListManager: GroceryListManager @ObservedObject var userSettings = UserSettings.shared @StateObject var viewModel = ViewModel() + @State private var reminderLists: [EKCalendar] = [] + @State private var remindersPermission: EKAuthorizationStatus = .notDetermined var body: some View { Form { @@ -54,6 +58,50 @@ struct SettingsView: View { } footer: { Text("The selected cookbook will open on app launch by default.") } + + Section { + Picker("Grocery list storage", selection: $userSettings.groceryListMode) { + ForEach(GroceryListMode.allValues, id: \.self) { mode in + Text(mode.descriptor()).tag(mode.rawValue) + } + } + + if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue { + if remindersPermission == .notDetermined { + Button("Grant Reminders Access") { + Task { + let granted = await groceryListManager.requestRemindersAccess() + remindersPermission = groceryListManager.remindersPermissionStatus + if granted { + reminderLists = groceryListManager.availableReminderLists() + } + } + } + } else if remindersPermission == .denied || remindersPermission == .restricted { + Text("Reminders access was denied. Please enable it in System Settings to use this feature.") + .foregroundStyle(.secondary) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } else if remindersPermission == .fullAccess { + Picker("Reminders list", selection: $userSettings.remindersListIdentifier) { + ForEach(reminderLists, id: \.calendarIdentifier) { list in + Text(list.title).tag(list.calendarIdentifier) + } + } + } + } + } header: { + Text("Grocery List") + } footer: { + if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue { + Text("Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app.") + } else { + Text("Grocery items are stored locally on this device.") + } + } Section { Toggle(isOn: $userSettings.expandNutritionSection) { @@ -187,6 +235,16 @@ struct SettingsView: View { } .task { await viewModel.getUserData() + remindersPermission = groceryListManager.remindersPermissionStatus + if remindersPermission == .fullAccess { + reminderLists = groceryListManager.availableReminderLists() + } + } + .onChange(of: userSettings.groceryListMode) { _, _ in + remindersPermission = groceryListManager.remindersPermissionStatus + if remindersPermission == .fullAccess { + reminderLists = groceryListManager.availableReminderLists() + } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift index 889ddf3..4936bf7 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -11,7 +11,7 @@ import SwiftUI struct GroceryListTabView: View { - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager var body: some View { NavigationStack { diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 3d040c8..f10b10b 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -12,7 +12,7 @@ import SwiftUI struct RecipeTabView: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var groceryList: GroceryList + @EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var viewModel: RecipeTabView.ViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -110,6 +110,7 @@ struct RecipeTabView: View { case .settings: SettingsView() .environmentObject(appState) + .environmentObject(groceryList) case .newRecipe: RecipeView(viewModel: RecipeView.ViewModel()) .environmentObject(appState) diff --git a/Nextcloud-Cookbook-iOS-Client-Info.plist b/Nextcloud-Cookbook-iOS-Client-Info.plist index 0c67376..f73bb80 100644 --- a/Nextcloud-Cookbook-iOS-Client-Info.plist +++ b/Nextcloud-Cookbook-iOS-Client-Info.plist @@ -1,5 +1,8 @@ - + + NSRemindersFullAccessUsageDescription + This app uses Reminders to save your grocery list items. +