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>
239 lines
7.4 KiB
Swift
239 lines
7.4 KiB
Swift
//
|
|
// GroceryListTabView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 23.01.24.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
import SwiftUI
|
|
|
|
|
|
struct GroceryListTabView: View {
|
|
@EnvironmentObject var groceryList: GroceryListManager
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
if groceryList.groceryDict.isEmpty {
|
|
EmptyGroceryListView()
|
|
} else {
|
|
List {
|
|
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
|
|
Section {
|
|
ForEach(groceryList.groceryDict[key]!.items) { item in
|
|
GroceryListItemView(item: item, toggleAction: {
|
|
groceryList.toggleItemChecked(item)
|
|
groceryList.objectWillChange.send()
|
|
}, deleteAction: {
|
|
groceryList.deleteItem(item.name, fromRecipe: key)
|
|
withAnimation {
|
|
groceryList.objectWillChange.send()
|
|
}
|
|
})
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text(groceryList.groceryDict[key]!.name)
|
|
.foregroundStyle(Color.nextcloudBlue)
|
|
Spacer()
|
|
Button {
|
|
groceryList.deleteGroceryRecipe(key)
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.foregroundStyle(Color.nextcloudBlue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.listStyle(.plain)
|
|
.navigationTitle("Grocery List")
|
|
.toolbar {
|
|
Button {
|
|
groceryList.deleteAll()
|
|
} label: {
|
|
Text("Delete")
|
|
.foregroundStyle(Color.nextcloudBlue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fileprivate struct GroceryListItemView: View {
|
|
let item: GroceryRecipeItem
|
|
let toggleAction: () -> Void
|
|
let deleteAction: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top) {
|
|
if item.isChecked {
|
|
Image(systemName: "checkmark.circle")
|
|
} else {
|
|
Image(systemName: "circle")
|
|
}
|
|
|
|
Text("\(item.name)")
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(5)
|
|
}
|
|
.padding(5)
|
|
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
|
.onTapGesture(perform: toggleAction)
|
|
.animation(.easeInOut, value: item.isChecked)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(action: deleteAction) {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
.tint(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fileprivate struct EmptyGroceryListView: View {
|
|
var body: some View {
|
|
List {
|
|
Text("You're all set for cooking 🍓")
|
|
.font(.headline)
|
|
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
|
|
.foregroundStyle(.secondary)
|
|
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.navigationTitle("Grocery List")
|
|
}
|
|
}
|
|
|
|
|
|
// Grocery List Logic
|
|
|
|
|
|
class GroceryRecipe: Identifiable, Codable {
|
|
let name: String
|
|
var items: [GroceryRecipeItem]
|
|
|
|
init(name: String, items: [GroceryRecipeItem]) {
|
|
self.name = name
|
|
self.items = items
|
|
}
|
|
|
|
init(name: String, item: GroceryRecipeItem) {
|
|
self.name = name
|
|
self.items = [item]
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class GroceryRecipeItem: Identifiable, Codable {
|
|
let name: String
|
|
var isChecked: Bool
|
|
|
|
init(_ name: String, isChecked: Bool = false) {
|
|
self.name = name
|
|
self.isChecked = isChecked
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor class GroceryList: ObservableObject {
|
|
let dataStore: DataStore = DataStore()
|
|
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
|
@Published var sortBySimilarity: Bool = false
|
|
|
|
|
|
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
|
Logger.view.debug("Adding item of recipe \(String(describing: recipeName))")
|
|
DispatchQueue.main.async {
|
|
if self.groceryDict[recipeId] != nil {
|
|
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
|
} else {
|
|
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
|
|
self.groceryDict[recipeId] = newRecipe
|
|
}
|
|
if saveGroceryDict {
|
|
self.save()
|
|
self.objectWillChange.send()
|
|
}
|
|
}
|
|
}
|
|
|
|
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
|
for item in items {
|
|
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
|
|
}
|
|
save()
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
|
Logger.view.debug("Deleting item \(itemName)")
|
|
guard let recipe = groceryDict[recipeId] else { return }
|
|
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
|
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
|
if groceryDict[recipeId]!.items.isEmpty {
|
|
groceryDict.removeValue(forKey: recipeId)
|
|
}
|
|
save()
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func deleteGroceryRecipe(_ recipeId: String) {
|
|
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
|
|
groceryDict.removeValue(forKey: recipeId)
|
|
save()
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func deleteAll() {
|
|
Logger.view.debug("Deleting all grocery items")
|
|
groceryDict = [:]
|
|
save()
|
|
}
|
|
|
|
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
|
Logger.view.debug("Item checked: \(groceryItem.name)")
|
|
groceryItem.isChecked.toggle()
|
|
save()
|
|
}
|
|
|
|
func containsItem(at recipeId: String, item: String) -> Bool {
|
|
guard let recipe = groceryDict[recipeId] else { return false }
|
|
if recipe.items.contains(where: { $0.name == item }) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsRecipe(_ recipeId: String) -> Bool {
|
|
return groceryDict[recipeId] != nil
|
|
}
|
|
|
|
func save() {
|
|
Task {
|
|
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
|
|
}
|
|
}
|
|
|
|
func load() async {
|
|
do {
|
|
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
|
|
fromPath: "grocery_list.data"
|
|
) else { return }
|
|
self.groceryDict = groceryDict
|
|
} catch {
|
|
Logger.view.error("Unable to load grocery list")
|
|
}
|
|
}
|
|
}
|
|
|
|
|