Improved Grocery List

This commit is contained in:
VincentMeilinger
2024-01-24 19:23:59 +01:00
parent 931364c57c
commit 48d3da1964
5 changed files with 189 additions and 67 deletions

View File

@@ -3170,6 +3170,9 @@
}, },
"You're all set for cooking 🍓" : { "You're all set for cooking 🍓" : {
},
"Your grocery list is stored locally and therefore not synchronized across different devices!" : {
} }
}, },
"version" : "1.0" "version" : "1.0"

View File

@@ -9,17 +9,20 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@StateObject var viewModel = MainViewModel() @StateObject var viewModel = MainViewModel()
@StateObject var groceryList = GroceryList()
@State var selectedCategory: Category? = nil @State var selectedCategory: Category? = nil
@State var showLoadingIndicator: Bool = false @State var showLoadingIndicator: Bool = false
enum Tab { enum Tab {
case recipes, search, shoppingList, settings case recipes, search, groceryList, settings
} }
var body: some View { var body: some View {
TabView { TabView {
RecipeTabView(selectedCategory: $selectedCategory, showLoadingIndicator: $showLoadingIndicator) RecipeTabView(selectedCategory: $selectedCategory, showLoadingIndicator: $showLoadingIndicator)
.environmentObject(viewModel) .environmentObject(viewModel)
.environmentObject(groceryList)
.tabItem { .tabItem {
Label("Recipes", systemImage: "book.closed.fill") Label("Recipes", systemImage: "book.closed.fill")
} }
@@ -27,16 +30,18 @@ struct MainView: View {
SearchTabView() SearchTabView()
.environmentObject(viewModel) .environmentObject(viewModel)
.environmentObject(groceryList)
.tabItem { .tabItem {
Label("Search", systemImage: "magnifyingglass") Label("Search", systemImage: "magnifyingglass")
} }
.tag(Tab.search) .tag(Tab.search)
GroceryListTabView() GroceryListTabView()
.environmentObject(groceryList)
.tabItem { .tabItem {
Label("Grocery List", systemImage: "storefront") Label("Grocery List", systemImage: "storefront")
} }
.tag(Tab.shoppingList) .tag(Tab.groceryList)
SettingsView() SettingsView()
.environmentObject(viewModel) .environmentObject(viewModel)
@@ -62,7 +67,7 @@ struct MainView: View {
} }
} }
showLoadingIndicator = false showLoadingIndicator = false
await GroceryList.shared.load() await groceryList.load()
} }
} }
} }

View File

@@ -352,7 +352,9 @@ fileprivate struct MoreInformationSection: View {
fileprivate struct RecipeIngredientSection: View { fileprivate struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@State var recipeDetail: RecipeDetail @State var recipeDetail: RecipeDetail
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
@@ -365,14 +367,16 @@ fileprivate struct RecipeIngredientSection: View {
} }
Spacer() Spacer()
Button { Button {
GroceryList.shared.addItems(recipeDetail.recipeIngredient) groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
} label: { } label: {
Image(systemName: "storefront") Image(systemName: "storefront")
} }
} }
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
IngredientListItem(ingredient: ingredient) IngredientListItem(ingredient: ingredient) {
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
}
.padding(4) .padding(4)
} }
@@ -397,19 +401,15 @@ fileprivate struct RecipeToolSection: View {
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: String @State var ingredient: String
let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false @State var isSelected: Bool = false
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
let maxDragDistance = 30.0 let maxDragDistance = 30.0
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
if dragOffset > 0 {
Image(systemName: "storefront")
.padding(2)
.background(Color.green)
.opacity((dragOffset - 10)/(maxDragDistance-10))
}
if isSelected { if isSelected {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
} else { } else {
@@ -432,11 +432,11 @@ fileprivate struct IngredientListItem: View {
.onChanged { gesture in .onChanged { gesture in
// Update drag offset as the user drags // Update drag offset as the user drags
let dragAmount = gesture.translation.width let dragAmount = gesture.translation.width
self.dragOffset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) self.dragOffset = max(0, min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)))
} }
.onEnded { gesture in .onEnded { gesture in
if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold
GroceryList.shared.addItem(ingredient) addToGroceryListAction()
} }
// Animate back to original position // Animate back to original position
withAnimation { withAnimation {
@@ -444,6 +444,17 @@ fileprivate struct IngredientListItem: View {
} }
} }
) )
.background {
if dragOffset > 0 {
HStack {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
.opacity((dragOffset - 10)/(maxDragDistance-10))
Spacer()
}
}
}
} }
} }

View File

@@ -10,21 +10,66 @@ import SwiftUI
struct GroceryListTabView: View { struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList
var body: some View { var body: some View {
NavigationStack { NavigationStack {
if GroceryList.shared.listItems.isEmpty { if groceryList.groceryDict.isEmpty {
List { EmptyGroceryListView()
Text("You're all set for cooking 🍓")
.font(.title2)
.foregroundStyle(.secondary)
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)
}
.padding()
.navigationTitle("Grocery List")
} else { } else {
List(GroceryList.shared.listItems) { item in 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) { HStack(alignment: .top) {
if item.isChecked { if item.isChecked {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
@@ -36,32 +81,56 @@ struct GroceryListTabView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(5) .lineLimit(5)
} }
.padding(5)
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary) .foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture { .onTapGesture(perform: toggleAction)
item.isChecked.toggle()
}
.animation(.easeInOut, value: item.isChecked) .animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button(action: deleteAction) {
GroceryList.shared.removeItem(item.name)
} label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
} }
.padding()
.navigationTitle("Grocery List")
}
}
}
} }
class GroceryListItem: ObservableObject, Identifiable, Codable { fileprivate struct EmptyGroceryListView: View {
var name: String 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 different devices!")
.foregroundStyle(.secondary)
}
.navigationTitle("Grocery List")
}
}
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 var isChecked: Bool
init(_ name: String, isChecked: Bool = false) { init(_ name: String, isChecked: Bool = false) {
@@ -73,46 +142,80 @@ class GroceryListItem: ObservableObject, Identifiable, Codable {
class GroceryList: ObservableObject { class GroceryList: ObservableObject {
static let shared: GroceryList = GroceryList()
let dataStore: DataStore = DataStore() let dataStore: DataStore = DataStore()
@Published var listItems: [GroceryListItem] = [] @Published var groceryDict: [String: GroceryRecipe] = [:]
@Published var sortBySimilarity: Bool = false
func addItem(_ name: String) { func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
listItems.append(GroceryListItem(name)) print("Adding item of recipe \(String(describing: recipeName))")
save() 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]) { func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
for item in items { for item in items {
addItem(item) addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
objectWillChange.send()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
print("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() save()
} }
func removeItem(_ name: String) { func deleteGroceryRecipe(_ recipeId: String) {
guard let ix = listItems.firstIndex(where: { item in print("Deleting grocery recipe with id \(recipeId)")
item.name == name groceryDict.removeValue(forKey: recipeId)
}) else { return } save()
listItems.remove(at: ix) }
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save() save()
} }
func save() { func save() {
Task { Task {
await dataStore.save(data: listItems, toPath: "grocery_list.data") await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
} }
} }
func load() async { func load() async {
do { do {
guard let listItems: [GroceryListItem] = try await dataStore.load( guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
fromPath: "grocery_list.data" fromPath: "grocery_list.data"
) else { return } ) else { return }
self.listItems = listItems self.groceryDict = groceryDict
} catch { } catch {
print("Unable to load grocery list") print("Unable to load grocery list")
} }
} }
} }