diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index c71e326..af531b4 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -47,6 +47,9 @@ A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; }; + A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; }; + A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; }; + A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; }; @@ -114,6 +117,9 @@ A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; + A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = ""; }; + A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = ""; }; + A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = ""; }; A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = ""; }; A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = ""; }; @@ -237,8 +243,9 @@ A70171BA2AB4980100064C43 /* Views */ = { isa = PBXGroup; children = ( - A7FB0D782B25C65200A3469E /* Onboarding */, A70171832AA8E71900064C43 /* MainView.swift */, + A977D0DC2B6002DA009783A9 /* Tabs */, + A7FB0D782B25C65200A3469E /* Onboarding */, A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, @@ -318,6 +325,16 @@ path = Onboarding; sourceTree = ""; }; + A977D0DC2B6002DA009783A9 /* Tabs */ = { + isa = PBXGroup; + children = ( + A977D0DD2B600300009783A9 /* SearchTabView.swift */, + A977D0DF2B600318009783A9 /* RecipeTabView.swift */, + A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, + ); + path = Tabs; + sourceTree = ""; + }; A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = { isa = PBXGroup; children = ( @@ -481,6 +498,7 @@ A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, + A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, @@ -505,12 +523,14 @@ A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, + A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, + A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; 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 6cfcb9b..432e8fb 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 4454279..635d000 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -402,6 +402,9 @@ } } } + }, + "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." : { + }, "Add new recipe" : { "localizations" : { @@ -1241,6 +1244,9 @@ } } } + }, + "Grocery List" : { + }, "If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : { "localizations" : { @@ -3161,6 +3167,9 @@ } } } + }, + "You're all set for cooking 🍓" : { + } }, "version" : "1.0" diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index cf25dda..1c204a7 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -11,8 +11,6 @@ import UIKit @MainActor class MainViewModel: ObservableObject { - @ObservedObject var userSettings = UserSettings.shared - @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:] @@ -30,10 +28,10 @@ import UIKit self.api = api self.dataStore = DataStore() - if userSettings.authString == "" { - let loginString = "\(userSettings.username):\(userSettings.token)" + if UserSettings.shared.authString == "" { + let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginData = loginString.data(using: String.Encoding.utf8)! - userSettings.authString = loginData.base64EncodedString() + UserSettings.shared.authString = loginData.base64EncodedString() } } @@ -51,7 +49,7 @@ import UIKit */ func getCategories() async { let (categories, _) = await api.getCategories( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let categories = categories { print("Successfully loaded categories") @@ -99,7 +97,7 @@ import UIKit func getServer(store: Bool = false) async -> Bool { let (recipes, _) = await api.getCategory( - auth: userSettings.authString, + auth: UserSettings.shared.authString, named: categoryString ) if let recipes = recipes { @@ -132,16 +130,16 @@ import UIKit for category in self.categories { await updateRecipeDetails(in: category.name) } - userSettings.lastUpdate = Date() + UserSettings.shared.lastUpdate = Date() } func updateRecipeDetails(in category: String) async { - guard userSettings.storeRecipes else { return } + guard UserSettings.shared.storeRecipes else { return } guard let recipes = self.recipes[category] else { return } for recipe in recipes { if needsUpdate(category: category, lastModified: recipe.dateModified) { print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)") - await updateRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages) + await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } else { print("\(recipe.name) is up to date.") } @@ -161,7 +159,7 @@ import UIKit */ func getRecipes() async -> [Recipe] { let (recipes, error) = await api.getRecipes( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let recipes = recipes { return recipes @@ -201,7 +199,7 @@ import UIKit func getServer() async -> RecipeDetail? { let (recipe, error) = await api.getRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id ) if let recipe = recipe { @@ -294,7 +292,7 @@ import UIKit func getServer() async -> UIImage? { let (image, _) = await api.getImage( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id, size: size ) @@ -370,7 +368,7 @@ import UIKit func getServer() async -> [RecipeKeyword]? { let (tags, _) = await api.getTags( - auth: userSettings.authString + auth: UserSettings.shared.authString ) return tags } @@ -423,7 +421,7 @@ import UIKit */ func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { let (error) = await api.deleteRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id ) @@ -454,7 +452,7 @@ import UIKit */ func checkServerConnection() async -> Bool { let (categories, _) = await api.getCategories( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let categories = categories { self.categories = categories @@ -483,12 +481,12 @@ import UIKit var error: NetworkError? = nil if createNew { error = await api.createRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, recipe: recipeDetail ) } else { error = await api.updateRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, recipe: recipeDetail ) } @@ -501,7 +499,7 @@ import UIKit func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) } let (recipeDetail, error) = await api.importRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, data: data ) if error != nil { @@ -509,6 +507,7 @@ import UIKit } return (recipeDetail, nil) } + } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 17e1794..e8a8c00 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -7,8 +7,66 @@ import SwiftUI - struct MainView: View { + @StateObject var viewModel = MainViewModel() + @State var selectedCategory: Category? = nil + @State var showLoadingIndicator: Bool = false + + enum Tab { + case recipes, search, shoppingList, settings + } + + var body: some View { + TabView { + RecipeTabView(selectedCategory: $selectedCategory, showLoadingIndicator: $showLoadingIndicator) + .environmentObject(viewModel) + .tabItem { + Label("Recipes", systemImage: "book.closed.fill") + } + .tag(Tab.recipes) + + SearchTabView() + .environmentObject(viewModel) + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + .tag(Tab.search) + + GroceryListTabView() + .tabItem { + Label("Grocery List", systemImage: "storefront") + } + .tag(Tab.shoppingList) + + SettingsView() + .environmentObject(viewModel) + .tabItem { + Label("Settings", systemImage: "gearshape") + } + .tag(Tab.settings) + } + .task { + showLoadingIndicator = true + await viewModel.getCategories() + await viewModel.updateAllRecipeDetails() + + // Open detail view for default category + if UserSettings.shared.defaultCategory != "" { + if let cat = viewModel.categories.first(where: { c in + if c.name == UserSettings.shared.defaultCategory { + return true + } + return false + }) { + self.selectedCategory = cat + } + } + showLoadingIndicator = false + await GroceryList.shared.load() + } + } +} +/*struct MainView: View { @ObservedObject var viewModel: MainViewModel @StateObject var userSettings: UserSettings = UserSettings.shared @@ -214,43 +272,5 @@ struct MainView: View { -struct RecipeSearchView: View { - @ObservedObject var viewModel: MainViewModel - @State var searchText: String = "" - @State var allRecipes: [Recipe] = [] - - var body: some View { - NavigationStack { - VStack { - ScrollView(showsIndicators: false) { - LazyVStack { - ForEach(recipesFiltered(), id: \.recipe_id) { recipe in - NavigationLink(value: recipe) { - RecipeCardView(viewModel: viewModel, recipe: recipe) - .shadow(radius: 2) - } - .buttonStyle(.plain) - } - } - } - .navigationDestination(for: Recipe.self) { recipe in - RecipeDetailView(viewModel: viewModel, recipe: recipe) - } - .searchable(text: $searchText, prompt: "Search recipes/keywords") - } - .navigationTitle("Search recipe") - } - .task { - allRecipes = await viewModel.getRecipes() - } - } - - func recipesFiltered() -> [Recipe] { - guard searchText != "" else { return allRecipes } - return allRecipes.filter { recipe in - recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term - (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term - } - } -} +*/ diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index fdf2bc9..575b515 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -364,10 +364,17 @@ fileprivate struct RecipeIngredientSection: View { SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings")) } Spacer() + Button { + GroceryList.shared.addItems(recipeDetail.recipeIngredient) + } label: { + Image(systemName: "storefront") + } } + ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in IngredientListItem(ingredient: ingredient) .padding(4) + } }.padding() } @@ -392,9 +399,17 @@ fileprivate struct RecipeToolSection: View { fileprivate struct IngredientListItem: View { @State var ingredient: String @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 { @@ -404,12 +419,31 @@ fileprivate struct IngredientListItem: View { Text("\(ingredient)") .multilineTextAlignment(.leading) .lineLimit(5) + Spacer() } .foregroundStyle(isSelected ? Color.secondary : Color.primary) .onTapGesture { isSelected.toggle() } .animation(.easeInOut, value: isSelected) + .offset(x: dragOffset, y: 0) + .gesture( + DragGesture() + .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)) + } + .onEnded { gesture in + if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold + GroceryList.shared.addItem(ingredient) + } + // Animate back to original position + withAnimation { + self.dragOffset = 0 + } + } + ) } } diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 9314a07..902f3d4 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -11,7 +11,7 @@ import SwiftUI struct SettingsView: View { - @ObservedObject var viewModel: MainViewModel + @EnvironmentObject var viewModel: MainViewModel @ObservedObject var userSettings = UserSettings.shared @State fileprivate var alertType: SettingsAlert = .NONE diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift new file mode 100644 index 0000000..0074325 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -0,0 +1,118 @@ +// +// GroceryListTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct GroceryListTabView: View { + 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") + } else { + List(GroceryList.shared.listItems) { item in + HStack(alignment: .top) { + if item.isChecked { + Image(systemName: "checkmark.circle") + } else { + Image(systemName: "circle") + } + + 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") + } + + } + } +} + + + +class GroceryListItem: ObservableObject, Identifiable, Codable { + var name: String + var isChecked: Bool + + init(_ name: String, isChecked: Bool = false) { + self.name = name + self.isChecked = isChecked + } +} + + + +class GroceryList: ObservableObject { + static let shared: GroceryList = GroceryList() + + let dataStore: DataStore = DataStore() + @Published var listItems: [GroceryListItem] = [] + + + func addItem(_ name: String) { + listItems.append(GroceryListItem(name)) + save() + } + + func addItems(_ items: [String]) { + for item in items { + addItem(item) + } + save() + } + + func removeItem(_ name: String) { + guard let ix = listItems.firstIndex(where: { item in + item.name == name + }) else { return } + listItems.remove(at: ix) + save() + } + + func save() { + Task { + await dataStore.save(data: listItems, toPath: "grocery_list.data") + } + } + + func load() async { + do { + guard let listItems: [GroceryListItem] = try await dataStore.load( + fromPath: "grocery_list.data" + ) else { return } + self.listItems = listItems + } catch { + print("Unable to load grocery list") + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift new file mode 100644 index 0000000..3ee7ff7 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -0,0 +1,166 @@ +// +// RecipeTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct RecipeTabView: View { + @Binding var selectedCategory: Category? + @Binding var showLoadingIndicator: Bool + + @EnvironmentObject var viewModel: MainViewModel + @StateObject var userSettings: UserSettings = UserSettings.shared + + @State private var showEditView: Bool = false + @State private var serverConnection: Bool = false + + + var body: some View { + NavigationSplitView { + List(selection: $selectedCategory) { + // Categories + ForEach(viewModel.categories) { category in + if category.recipe_count != 0 { + NavigationLink(value: category) { + HStack(alignment: .center) { + if selectedCategory != nil && category.name == selectedCategory!.name { + Image(systemName: "book") + } else { + Image(systemName: "book.closed.fill") + } + Text(category.name == "*" ? String(localized: "Other") : category.name) + .font(.system(size: 20, weight: .medium, design: .default)) + Spacer() + Text("\(category.recipe_count)") + .font(.system(size: 15, weight: .bold, design: .default)) + .foregroundStyle(Color.background) + .frame(width: 25, height: 25, alignment: .center) + .minimumScaleFactor(0.5) + .background { + Circle() + .foregroundStyle(Color.secondary) + } + }.padding(7) + } + } + } + } + .navigationTitle("Cookbooks") + .toolbar { + RecipeTabViewToolBar( + viewModel: viewModel, + showEditView: $showEditView, + serverConnection: $serverConnection, + showLoadingIndicator: $showLoadingIndicator + ) + } + } detail: { + NavigationStack { + if let category = selectedCategory { + CategoryDetailView( + categoryName: category.name, + viewModel: viewModel, + showEditView: $showEditView + ) + .id(category.id) // Workaround: This is needed to update the detail view when the selection changes + } + } + } + .tint(.nextcloudBlue) + .sheet(isPresented: $showEditView) { + RecipeEditView( + viewModel: + RecipeEditViewModel( + mainViewModel: viewModel, + uploadNew: true + ), + isPresented: $showEditView + ) + } + .task { + self.serverConnection = await viewModel.checkServerConnection() + } + .refreshable { + self.serverConnection = await viewModel.checkServerConnection() + await viewModel.getCategories() + } + } +} + + +fileprivate struct RecipeTabViewToolBar: ToolbarContent { + @ObservedObject var viewModel: MainViewModel + @Binding var showEditView: Bool + @Binding var serverConnection: Bool + @Binding var showLoadingIndicator: Bool + @State private var presentPopover: Bool = false + + var body: some ToolbarContent { + // Top left menu toolbar item + ToolbarItem(placement: .topBarLeading) { + Menu { + Button { + Task { + showLoadingIndicator = true + UserSettings.shared.lastUpdate = Date.distantPast + await viewModel.getCategories() + for category in viewModel.categories { + await viewModel.getCategory(named: category.name, fetchMode: .preferServer) + } + await viewModel.updateAllRecipeDetails() + showLoadingIndicator = false + } + } label: { + Text("Refresh all") + Image(systemName: "icloud.and.arrow.down") + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + + // Server connection indicator + ToolbarItem(placement: .topBarTrailing) { + Button { + print("Check server connection") + presentPopover = true + } label: { + if showLoadingIndicator { + ProgressView() + } else if serverConnection { + Image(systemName: "checkmark.icloud") + } else { + Image(systemName: "xmark.icloud") + } + }.popover(isPresented: $presentPopover) { + VStack(alignment: .leading) { + Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server.")) + .bold() + + Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))") + .font(.caption) + .foregroundStyle(Color.secondary) + } + .padding() + .presentationCompactAdaptation(.popover) + } + } + + // Create new recipes + ToolbarItem(placement: .topBarTrailing) { + Button { + print("Add new recipe") + showEditView = true + } label: { + Image(systemName: "plus.circle.fill") + } + } + + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift new file mode 100644 index 0000000..96add22 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -0,0 +1,58 @@ +// +// SearchTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct SearchTabView: View { + @EnvironmentObject var viewModel: MainViewModel + + var body: some View { + RecipeSearchView(viewModel: viewModel) + } +} + +struct RecipeSearchView: View { + @ObservedObject var viewModel: MainViewModel + @State var searchText: String = "" + @State var allRecipes: [Recipe] = [] + + var body: some View { + NavigationStack { + VStack { + ScrollView(showsIndicators: false) { + LazyVStack { + ForEach(recipesFiltered(), id: \.recipe_id) { recipe in + NavigationLink(value: recipe) { + RecipeCardView(viewModel: viewModel, recipe: recipe) + .shadow(radius: 2) + } + .buttonStyle(.plain) + } + } + } + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetailView(viewModel: viewModel, recipe: recipe) + } + .searchable(text: $searchText, prompt: "Search recipes/keywords") + } + .navigationTitle("Search recipe") + } + .task { + allRecipes = await viewModel.getRecipes() + } + } + + func recipesFiltered() -> [Recipe] { + guard searchText != "" else { return allRecipes } + return allRecipes.filter { recipe in + recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term + (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term + } + } +}