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

@@ -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

View File

@@ -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 = "<group>"; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = "<group>"; };
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = "<group>"; };
D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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;
};

View 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()
}
}

View 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
}

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)
}
}
}

View File

@@ -121,6 +121,18 @@ class UserSettings: ObservableObject {
}
}
@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 ?? ""
self.token = UserDefaults.standard.object(forKey: "token") 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 != "" {

View File

@@ -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"

View File

@@ -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)
}
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()

View File

@@ -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 = ""

View File

@@ -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

View File

@@ -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

View File

@@ -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 {
@@ -55,6 +59,50 @@ struct SettingsView: View {
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) {
Text("Expand nutrition section")
@@ -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()
}
}
}

View File

@@ -11,7 +11,7 @@ import SwiftUI
struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList
@EnvironmentObject var groceryList: GroceryListManager
var body: some View {
NavigationStack {

View File

@@ -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)

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>NSRemindersFullAccessUsageDescription</key>
<string>This app uses Reminders to save your grocery list items.</string>
</dict>
</plist>