diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index 432e8fb..6493ce8 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 635d000..6bb7a98 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -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" diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index e8a8c00..5222847 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -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() } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 575b515..848e59a 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -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() + } + } + } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift index 0074325..6ab5302 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -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") } } } + +