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 🍓" : {
},
"Your grocery list is stored locally and therefore not synchronized across different devices!" : {
}
},
"version" : "1.0"

View File

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

View File

@@ -352,7 +352,9 @@ fileprivate struct MoreInformationSection: View {
fileprivate struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@State var recipeDetail: RecipeDetail
var body: some View {
VStack(alignment: .leading) {
HStack {
@@ -365,14 +367,16 @@ fileprivate struct RecipeIngredientSection: View {
}
Spacer()
Button {
GroceryList.shared.addItems(recipeDetail.recipeIngredient)
groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
} label: {
Image(systemName: "storefront")
}
}
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
IngredientListItem(ingredient: ingredient)
IngredientListItem(ingredient: ingredient) {
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
}
.padding(4)
}
@@ -397,19 +401,15 @@ fileprivate struct RecipeToolSection: View {
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: String
let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false
@State private var dragOffset: CGFloat = 0
let maxDragDistance = 30.0
var body: some View {
HStack(alignment: .top) {
if dragOffset > 0 {
Image(systemName: "storefront")
.padding(2)
.background(Color.green)
.opacity((dragOffset - 10)/(maxDragDistance-10))
}
if isSelected {
Image(systemName: "checkmark.circle")
} else {
@@ -432,11 +432,11 @@ fileprivate struct IngredientListItem: View {
.onChanged { gesture in
// Update drag offset as the user drags
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
if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold
GroceryList.shared.addItem(ingredient)
addToGroceryListAction()
}
// Animate back to original position
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,58 +10,127 @@ import SwiftUI
struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList
var body: some View {
NavigationStack {
if GroceryList.shared.listItems.isEmpty {
List {
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")
if groceryList.groceryDict.isEmpty {
EmptyGroceryListView()
} else {
List(GroceryList.shared.listItems) { item in
HStack(alignment: .top) {
if item.isChecked {
Image(systemName: "checkmark.circle")
} else {
Image(systemName: "circle")
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)
}
}
}
Text("\(item.name)")
.multilineTextAlignment(.leading)
.lineLimit(5)
}
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture {
item.isChecked.toggle()
}
.animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
GroceryList.shared.removeItem(item.name)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
.padding()
.listStyle(.plain)
.navigationTitle("Grocery List")
.toolbar {
Button {
groceryList.deleteAll()
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
}
}
}
}
}
}
class GroceryListItem: ObservableObject, Identifiable, Codable {
var name: String
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 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
init(_ name: String, isChecked: Bool = false) {
@@ -73,46 +142,80 @@ class GroceryListItem: ObservableObject, Identifiable, Codable {
class GroceryList: ObservableObject {
static let shared: GroceryList = GroceryList()
let dataStore: DataStore = DataStore()
@Published var listItems: [GroceryListItem] = []
@Published var groceryDict: [String: GroceryRecipe] = [:]
@Published var sortBySimilarity: Bool = false
func addItem(_ name: String) {
listItems.append(GroceryListItem(name))
save()
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("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]) {
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
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()
}
func removeItem(_ name: String) {
guard let ix = listItems.firstIndex(where: { item in
item.name == name
}) else { return }
listItems.remove(at: ix)
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
}
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
}
func save() {
Task {
await dataStore.save(data: listItems, toPath: "grocery_list.data")
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
}
}
func load() async {
do {
guard let listItems: [GroceryListItem] = try await dataStore.load(
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
fromPath: "grocery_list.data"
) else { return }
self.listItems = listItems
self.groceryDict = groceryDict
} catch {
print("Unable to load grocery list")
}
}
}