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

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

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

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)