Add user-facing appearance setting (System/Light/Dark) wired via preferredColorScheme at the app root. Replace hardcoded .black tints and foreground styles with .primary so toolbar buttons and text remain visible in dark mode. Remove profile picture from settings and SwiftSoup from acknowledgements. 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(.primary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
|