Improved Grocery List
This commit is contained in:
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,58 +10,127 @@ 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 {
|
||||||
HStack(alignment: .top) {
|
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
|
||||||
if item.isChecked {
|
Section {
|
||||||
Image(systemName: "checkmark.circle")
|
ForEach(groceryList.groceryDict[key]!.items) { item in
|
||||||
} else {
|
GroceryListItemView(item: item, toggleAction: {
|
||||||
Image(systemName: "circle")
|
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()
|
|
||||||
.navigationTitle("Grocery List")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Grocery List")
|
||||||
|
.toolbar {
|
||||||
|
Button {
|
||||||
|
groceryList.deleteAll()
|
||||||
|
} label: {
|
||||||
|
Text("Delete")
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryListItem: ObservableObject, Identifiable, Codable {
|
fileprivate struct GroceryListItemView: View {
|
||||||
var name: String
|
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
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user