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 != "" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
|
||||
|
||||
struct GroceryListTabView: View {
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user