Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift
Hendrik Hogertz 02118e3d7a Add dark mode support with appearance picker and fix hardcoded colors
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>
2026-02-15 06:31:14 +01:00

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