Improved Grocery List
This commit is contained in:
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,66 @@ 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
|
||||
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")
|
||||
@@ -36,32 +81,56 @@ struct GroceryListTabView: View {
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
}
|
||||
.padding(5)
|
||||
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
||||
.onTapGesture {
|
||||
item.isChecked.toggle()
|
||||
}
|
||||
.onTapGesture(perform: toggleAction)
|
||||
.animation(.easeInOut, value: item.isChecked)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
GroceryList.shared.removeItem(item.name)
|
||||
} label: {
|
||||
Button(action: deleteAction) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 GroceryListItem: ObservableObject, Identifiable, Codable {
|
||||
var name: String
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user