From 85a8e631d04fbd6de03c8a1eb9c28752fd802a4f Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:23:05 +0200 Subject: [PATCH] Recipe creation, editing and deletion are now supported --- .../project.pbxproj | 8 +- .../Data/DataModels.swift | 14 ++ .../Extensions/DateExtension.swift | 21 ++- .../Extensions/DateFormatterExtension.swift | 60 ++++--- .../Network/NetworkRequests.swift | 2 + .../Nextcloud_Cookbook_iOS_ClientApp.swift | 2 +- .../ViewModels/MainViewModel.swift | 17 +- .../Views/CategoryCardView.swift | 32 ---- .../Views/CategoryDetailView.swift | 7 +- .../Views/CategoryPickerView.swift | 71 ++++++++ .../Views/KeywordPickerView.swift | 134 +++++++++----- .../Views/MainView.swift | 47 ++--- .../Views/RecipeDetailView.swift | 21 ++- .../Views/RecipeEditView.swift | 163 +++++++++++++++++- 14 files changed, 453 insertions(+), 146 deletions(-) delete mode 100644 Nextcloud Cookbook iOS Client/Views/CategoryCardView.swift create mode 100644 Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index f3b982b..4773657 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */; }; - A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BB2AB4983500064C43 /* CategoryCardView.swift */; }; A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; }; A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; }; A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; }; @@ -34,6 +33,7 @@ A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; }; A70D7CA32AC74B3B00D53DBF /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */; }; A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; + A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,7 +70,6 @@ A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = ""; }; A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatterExtension.swift; sourceTree = ""; }; - A70171BB2AB4983500064C43 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = ""; }; A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = ""; }; A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = ""; }; @@ -85,6 +84,7 @@ A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = ""; }; A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = ""; }; + A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -196,7 +196,6 @@ isa = PBXGroup; children = ( A70171832AA8E71900064C43 /* MainView.swift */, - A70171BB2AB4983500064C43 /* CategoryCardView.swift */, A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, @@ -204,6 +203,7 @@ A70171C82AB4CBB400064C43 /* OnboardingView.swift */, A70171CC2AB501B100064C43 /* SettingsView.swift */, A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, + A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, ); path = Views; sourceTree = ""; @@ -366,11 +366,11 @@ A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */, - A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */, A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, + A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 385855c..aeaeb85 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -13,6 +13,10 @@ struct Category: Codable { let recipe_count: Int } +extension Category: Identifiable, Hashable { + var id: String { name } +} + struct Recipe: Codable { let name: String let keywords: String @@ -23,6 +27,10 @@ struct Recipe: Codable { let recipe_id: Int } +extension Recipe: Identifiable, Hashable { + var id: String { name } +} + struct RecipeDetail: Codable { var name: String var keywords: String @@ -140,3 +148,9 @@ struct MetaData: Codable { let status: String let statuscode: Int } + + +// Networking +struct ServerMessage: Decodable { + let msg: String +} diff --git a/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift index 77c9e19..1f049c6 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift @@ -11,11 +11,30 @@ extension Date { static var zero: Date { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm" - if let date = dateFormatter.date(from:"00:00") { return date } else { return Date() } } + + static func toPTRepresentation(date: Date) -> String? { + // PT0H18M0S + let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date) + if let hour = dateComponents.hour, let minute = dateComponents.minute { + return "PT\(hour)H\(minute)M0S" + } + return nil + } + + static func fromPTRepresentation(_ representation: String) -> Date { + let (hour, minute) = DateFormatter.stringToComponents(duration: representation) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm" + if let date = dateFormatter.date(from:"\(hour):\(minute)") { + return date + } else { + return Date() + } + } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift index 2de68f3..e596f8a 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift @@ -13,29 +13,45 @@ extension Formatter { formatter.unitsStyle = .positional return formatter }() -} - -func formatDate(duration: String) -> String { - var duration = duration - if duration.hasPrefix("PT") { duration.removeFirst(2) } - var hour: Int = 0, minute: Int = 0 - if let index = duration.firstIndex(of: "H") { - hour = Int(duration[.. String { + var duration = duration + if duration.hasPrefix("PT") { duration.removeFirst(2) } + var hour: Int = 0, minute: Int = 0 + if let index = duration.firstIndex(of: "H") { + hour = Int(duration[.. (Int, Int) { + var duration = duration + if duration.hasPrefix("PT") { duration.removeFirst(2) } + var hour: Int = 0, minute: Int = 0 + if let index = duration.firstIndex(of: "H") { + hour = Int(duration[.. (String) = { recipeId, thumb in @@ -43,6 +43,7 @@ import SwiftUI ) { self.categories = categoryList } + print(self.categories) } /// Try to load the recipe list from store or the server. @@ -56,7 +57,9 @@ import SwiftUI needsUpdate: needsUpdate ) { recipes[categoryName] = recipeList + print(recipeList) } + } /// Try to load the recipe details from cache. If not found, try to load from store or the server. @@ -116,8 +119,8 @@ import SwiftUI print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))") // If the image needs an update, request it from the server and overwrite the stored image if needsUpdate { - guard let apiInterface = apiInterface else { return nil } - if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { + guard let apiController = apiController else { return nil } + if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) { guard let image = UIImage(data: data) else { imageCache[recipeId] = RecipeImage(imageExists: false) return nil @@ -154,8 +157,8 @@ import SwiftUI // Try to load from the server. Store if successfull. print("Attempting to load image from server ...") - guard let apiInterface = apiInterface else { return nil } - if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { + guard let apiController = apiController else { return nil } + if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) { print("Image data received.") // Create empty RecipeImage for each recipe even if no image found, so that further server requests are only sent if explicitly requested. guard let image = UIImage(data: data) else { @@ -190,9 +193,9 @@ extension MainViewModel { print("Data found locally.") return data } else { - guard let apiInterface = apiInterface else { return nil } + guard let apiController = apiController else { return nil } let request = RequestWrapper.jsonGetRequest(path: networkPath) - let (data, error): (T?, Error?) = await apiInterface.sendDataRequest(request) + let (data, error): (T?, Error?) = await apiController.sendDataRequest(request) print(error as Any) if let data = data { await dataStore.save(data: data, toPath: localPath) diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryCardView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryCardView.swift deleted file mode 100644 index 1fa627d..0000000 --- a/Nextcloud Cookbook iOS Client/Views/CategoryCardView.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// CategoryCardView.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 15.09.23. -// - -import Foundation -import SwiftUI - -struct CategoryCardView: View { - @State var category: Category - - var body: some View { - ZStack { - Image("cookbook-category") - .resizable() - .scaledToFit() - .overlay( - VStack { - Spacer() - Text(category.name == "*" ? "Other" : category.name) - .font(.headline) - .lineLimit(2) - .foregroundStyle(.white) - .padding() - } - ) - .padding() - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index 2df9265..871c203 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -10,7 +10,7 @@ import SwiftUI -struct RecipeBookView: View { +struct CategoryDetailView: View { @State var categoryName: String @State var searchText: String = "" @ObservedObject var viewModel: MainViewModel @@ -19,10 +19,13 @@ struct RecipeBookView: View { ScrollView(showsIndicators: false) { LazyVStack { ForEach(recipesFiltered(), id: \.recipe_id) { recipe in - NavigationLink(destination: RecipeDetailView(viewModel: viewModel, recipe: recipe)) { + NavigationLink() { + RecipeDetailView(viewModel: viewModel, recipe: recipe).id(recipe.recipe_id) + } label: { RecipeCardView(viewModel: viewModel, recipe: recipe) } .buttonStyle(.plain) + } } } diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift new file mode 100644 index 0000000..5375afb --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift @@ -0,0 +1,71 @@ +// +// CategoryPickerView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 03.10.23. +// + +import Foundation +import SwiftUI + + + +struct CategoryPickerView: View { + @State var title: String + @State var searchSuggestions: [String] + @Binding var selection: String + @State var searchText: String = "" + + var body: some View { + VStack { + TextField(title, text: $searchText) + .textFieldStyle(.roundedBorder) + .padding() + List { + if searchText != "" { + HStack { + if selection.contains(searchText) { + Image(systemName: "checkmark.circle.fill") + } + Text(searchText) + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .foregroundStyle(Color("backgroundHighlight")) + ) + .onTapGesture { + selection = searchText + } + } + ForEach(suggestionsFiltered(), id: \.self) { suggestion in + HStack { + if selection.contains(suggestion) { + Image(systemName: "checkmark.circle.fill") + } + Text(suggestion) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .foregroundStyle(Color("backgroundHighlight")) + ) + .onTapGesture { + selection = suggestion + } + } + } + Spacer() + } + .navigationTitle(title) + } + + func suggestionsFiltered() -> [String] { + guard searchText != "" else { return searchSuggestions } + return searchSuggestions.filter { suggestion in + suggestion.lowercased().contains(searchText.lowercased()) + } + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift b/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift index 5064349..33d2ac3 100644 --- a/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift @@ -8,74 +8,120 @@ import Foundation import SwiftUI +struct Keyword: Identifiable { + let id = UUID() + let name: String + + init(_ name: String) { + self.name = name + } +} struct KeywordPickerView: View { @State var title: String - @State var searchSuggestions: [String] + @State var searchSuggestions: [Keyword] @Binding var selection: [String] @State var searchText: String = "" - var columns: [GridItem] = [GridItem(.adaptive(minimum: 120), spacing: 0)] + var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 5)] var body: some View { - VStack { + VStack(alignment: .leading) { TextField(title, text: $searchText) .textFieldStyle(.roundedBorder) .padding() - LazyVGrid(columns: columns, spacing: 5) { - if searchText != "" { - HStack { - if selection.contains(searchText) { - Image(systemName: "checkmark.circle.fill") + ScrollView { + LazyVGrid(columns: columns, spacing: 5) { + if searchText != "" { + KeywordItemView( + keyword: Keyword(searchText), + isSelected: selection.contains(searchText) + ) { keyword in + if selection.contains(keyword.name) { + selection.removeAll(where: { s in + s == keyword.name ? true : false + }) + searchSuggestions.removeAll(where: { s in + s.name == keyword.name ? true : false + }) + } else { + selection.append(keyword.name) + } } - Text(searchText) } - .padding() - .background( - RoundedRectangle(cornerRadius: 15) - .foregroundStyle(Color("backgroundHighlight")) - ) - .onTapGesture { - if selection.contains(searchText) { - selection.removeAll(where: { s in - s == searchText ? true : false - }) - } else { - selection.append(searchText) - searchSuggestions.append(searchText) + ForEach(suggestionsFiltered(), id: \.id) { suggestion in + KeywordItemView( + keyword: suggestion, + isSelected: selection.contains(suggestion.name) + ) { keyword in + if selection.contains(keyword.name) { + selection.removeAll(where: { s in + s == keyword.name ? true : false + }) + } else { + selection.append(keyword.name) + } } } } - ForEach(suggestionsFiltered(), id: \.self) { suggestion in - HStack { - if selection.contains(suggestion) { - Image(systemName: "checkmark.circle.fill") - } - Text(suggestion) - } - .padding() - .background( - RoundedRectangle(cornerRadius: 15) - .foregroundStyle(Color("backgroundHighlight")) - ) - .onTapGesture { - if selection.contains(suggestion) { - selection.removeAll(where: { s in - s == suggestion ? true : false - }) - } else { - selection.append(suggestion) + Divider().padding() + HStack { + Text("Selected keywords:") + .font(.headline) + .padding() + Spacer() + } + LazyVGrid(columns: columns, spacing: 5) { + ForEach(selection, id: \.self) { suggestion in + KeywordItemView( + keyword: Keyword(suggestion), + isSelected: true + ) { keyword in + if selection.contains(keyword.name) { + selection.removeAll(where: { s in + s == keyword.name ? true : false + }) + } else { + selection.append(keyword.name) + } } } } + Spacer() } - Spacer() } + .navigationTitle(title) } - func suggestionsFiltered() -> [String] { + func suggestionsFiltered() -> [Keyword] { guard searchText != "" else { return searchSuggestions } return searchSuggestions.filter { suggestion in - suggestion.lowercased().contains(searchText.lowercased()) + suggestion.name.lowercased().contains(searchText.lowercased()) + } + } +} + + + +struct KeywordItemView: View { + var keyword: Keyword + var isSelected: Bool + var tapped: (Keyword) -> () + var body: some View { + HStack { + if isSelected { + Image(systemName: "checkmark.circle.fill") + } + Text(keyword.name) + .lineLimit(2) + + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .foregroundStyle(Color("backgroundHighlight")) + ) + .onTapGesture { + tapped(keyword) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 5b944ae..e1b132b 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -7,35 +7,29 @@ import SwiftUI + struct MainView: View { @ObservedObject var viewModel: MainViewModel @ObservedObject var userSettings: UserSettings + @State private var selectedCategory: Category? = nil @State private var showEditView: Bool = false var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] var body: some View { - NavigationView { - ScrollView(.vertical, showsIndicators: false) { - LazyVGrid(columns: columns, spacing: 0) { - ForEach(viewModel.categories, id: \.name) { category in - if category.recipe_count != 0 { - NavigationLink( - destination: RecipeBookView( - categoryName: category.name, - viewModel: viewModel - ) - ) { - CategoryCardView(category: category) - } - .buttonStyle(.plain) - } + NavigationSplitView { + List(viewModel.categories, selection: $selectedCategory) { category in + if category.recipe_count != 0 { + NavigationLink(value: category) { + HStack(alignment: .center) { + Image(systemName: "book.closed.fill") + Text(category.name) + .font(.system(size: 20, weight: .light, design: .serif)) + .italic() + }.padding(7) } } } - /*.navigationDestination(isPresented: $showEditView) { - RecipeEditView() - }*/ .navigationTitle("Cookbooks") .toolbar { Menu { @@ -68,9 +62,20 @@ struct MainView: View { Image(systemName: "gearshape") } } - .background( - NavigationLink(destination: RecipeEditView(), isActive: $showEditView) { EmptyView() } - ) + .navigationDestination(isPresented: $showEditView) { + RecipeEditView(viewModel: viewModel, isPresented: $showEditView) + } + + } detail: { + NavigationStack { + if let category = selectedCategory { + CategoryDetailView( + categoryName: category.name, + viewModel: viewModel + ) + .id(category.id) // Workaround: This is needed to update the detail view when the selection changes + } + } } .tint(.nextcloudBlue) diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 8d3e0b5..9d5abc8 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -16,6 +16,8 @@ struct RecipeDetailView: View { @State var recipeImage: UIImage? @State var showTitle: Bool = false @State var isDownloaded: Bool? = nil + @State private var presentEditView: Bool = false + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading) { @@ -69,6 +71,18 @@ struct RecipeDetailView: View { } .navigationBarTitleDisplayMode(.inline) .navigationTitle(showTitle ? recipe.name : "") + .toolbar { + if let recipeDetail = recipeDetail { + NavigationLink { + RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false).tag("RecipeEditView") + } label: { + HStack { + Image(systemName: "pencil") + Text("Edit") + } + } + } + } .task { recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) @@ -78,7 +92,6 @@ struct RecipeDetailView: View { recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) } - } } @@ -91,7 +104,7 @@ struct RecipeDurationSection: View { if let prepTime = recipeDetail.prepTime { VStack { SecondaryLabel(text: "Prep time") - Text(formatDate(duration: prepTime)) + Text(DateFormatter.formatDate(duration: prepTime)) .lineLimit(1) }.padding() } @@ -99,7 +112,7 @@ struct RecipeDurationSection: View { if let cookTime = recipeDetail.cookTime { VStack { SecondaryLabel(text: "Cook time") - Text(formatDate(duration: cookTime)) + Text(DateFormatter.formatDate(duration: cookTime)) .lineLimit(1) }.padding() } @@ -107,7 +120,7 @@ struct RecipeDurationSection: View { if let totalTime = recipeDetail.totalTime { VStack { SecondaryLabel(text: "Total time") - Text(formatDate(duration: totalTime)) + Text(DateFormatter.formatDate(duration: totalTime)) .lineLimit(1) }.padding() } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index c25abad..fcbf4d0 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -11,33 +11,56 @@ import PhotosUI struct RecipeEditView: View { + @ObservedObject var viewModel: MainViewModel @State var recipe: RecipeDetail = RecipeDetail() + @Binding var isPresented: Bool @State var image: PhotosPickerItem? = nil @State var times = [Date.zero, Date.zero, Date.zero] + @State var uploadNew: Bool = true @State var searchText: String = "" @State var keywords: [String] = [] - init(recipe: RecipeDetail? = nil) { - self.recipe = recipe ?? RecipeDetail() - } + @State private var alertMessage: String = "" + @State private var presentAlert: Bool = false var body: some View { Form { TextField("Title", text: $recipe.name) - TextField("Description", text: $recipe.description) + Section { + TextEditor(text: $recipe.description) + } header: { + Text("Description") + } + /* PhotosPicker(selection: $image, matching: .images, photoLibrary: .shared()) { Image(systemName: "photo") .symbolRenderingMode(.multicolor) } .buttonStyle(.borderless) - + */ Section() { + NavigationLink(recipe.recipeCategory == "" ? "Category" : "Category: \(recipe.recipeCategory)") { + CategoryPickerView(title: "Category", searchSuggestions: [], selection: $recipe.recipeCategory) + } NavigationLink("Keywords") { - KeywordPickerView(title: "Keyword", searchSuggestions: [], selection: $keywords) + KeywordPickerView( + title: "Keywords", + searchSuggestions: [ + Keyword("Hauptspeisen"), + Keyword("Lecker"), + Keyword("Trinken"), + Keyword("Essen"), + Keyword("Nachspeisen"), + Keyword("Futter"), + Keyword("Apfel"), + Keyword("test") + ], + selection: $keywords + ) } } header: { - Text("Keywords") + Text("Discoverability") } footer: { ScrollView(.horizontal) { HStack { @@ -63,7 +86,131 @@ struct RecipeEditView: View { EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient) EditableListSection(title: "Tools", items: $recipe.tool) EditableListSection(title: "Instructions", items: $recipe.recipeInstructions) - }.navigationTitle("New Recipe") + }.navigationTitle("Edit your recipe") + .toolbar { + Menu { + Button { + print("Delete recipe.") + deleteRecipe() + self.isPresented = false + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + Text("Delete recipe") + .foregroundStyle(.red) + } + } label: { + Image(systemName: "ellipsis.circle") + } + Button() { + if uploadNew { + uploadNewRecipe() + } else { + uploadEditedRecipe() + } + } label: { + Image(systemName: "icloud.and.arrow.up") + Text(uploadNew ? "Upload" : "Update") + .bold() + } + } + .onAppear { + if uploadNew { return } + if let prepTime = recipe.prepTime { + self.times[0] = Date.fromPTRepresentation(prepTime) + } + if let cookTime = recipe.cookTime { + self.times[1] = Date.fromPTRepresentation(cookTime) + } + if let totalTime = recipe.totalTime { + self.times[2] = Date.fromPTRepresentation(totalTime) + } + + for keyword in recipe.keywords.components(separatedBy: ",") { + keywords.append(keyword) + } + } + .alert(alertMessage, isPresented: $presentAlert) { + Button("Ok", role: .cancel) { + self.isPresented = false + } + } + } + + func createRecipe() { + print(self.recipe.name) + if let date = Date.toPTRepresentation(date: times[0]) { + self.recipe.prepTime = date + } + if let date = Date.toPTRepresentation(date: times[1]) { + self.recipe.cookTime = date + } + if let date = Date.toPTRepresentation(date: times[2]) { + self.recipe.totalTime = date + } + self.recipe.keywords = self.keywords.joined(separator: ",") + } + + func uploadNewRecipe() { + print("Uploading new recipe.") + createRecipe() + let request = RequestWrapper.customRequest( + method: .POST, + path: .NEW_RECIPE, + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true), + HeaderField.contentType(value: .JSON) + ], + body: JSONEncoder.safeEncode(self.recipe) + ) + sendRequest(request) + } + + func uploadEditedRecipe() { + print("Uploading changed recipe.") + guard let recipeId = Int(recipe.id) else { return } + createRecipe() + let request = RequestWrapper.customRequest( + method: .PUT, + path: .RECIPE_DETAIL(recipeId: recipeId), + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true), + HeaderField.contentType(value: .JSON) + ], + body: JSONEncoder.safeEncode(self.recipe) + ) + sendRequest(request) + } + + func deleteRecipe() { + guard let recipeId = Int(recipe.id) else { return } + let request = RequestWrapper.customRequest( + method: .DELETE, + path: .RECIPE_DETAIL(recipeId: recipeId), + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true) + ] + ) + sendRequest(request) + } + + func sendRequest(_ request: RequestWrapper) { + Task { + guard let apiController = viewModel.apiController else { return } + let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) + guard let data = data else { return } + do { + let error = try JSONDecoder().decode(ServerMessage.self, from: data) + alertMessage = error.msg + presentAlert = true + } catch { + self.isPresented = false + await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true) + } + } } }