// // RecipeEditView.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 29.09.23. // import Foundation import SwiftUI import PhotosUI fileprivate enum ErrorMessages: Error { case NO_TITLE, DUPLICATE, UPLOAD_ERROR, CONFIRM_DELETE, GENERIC, CUSTOM(title: LocalizedStringKey, description: LocalizedStringKey) var localizedDescription: LocalizedStringKey { switch self { case .NO_TITLE: return "Please enter a recipe name." case .DUPLICATE: return "A recipe with that name already exists." case .UPLOAD_ERROR: return "Unable to upload your recipe. Please check your internet connection." case .CONFIRM_DELETE: return "This action is not reversible!" case .CUSTOM(title: _, description: let description): return description default: return "An unknown error occured." } } var localizedTitle: LocalizedStringKey { switch self { case .NO_TITLE: return "Missing recipe name." case .DUPLICATE: return "Duplicate recipe." case .UPLOAD_ERROR: return "Network error." case .CONFIRM_DELETE: return "Delete recipe?" case .CUSTOM(title: let title, description: _): return title default: return "Error." } } } struct RecipeEditView: View { @ObservedObject var viewModel: MainViewModel @State var recipe: RecipeDetail = RecipeDetail() @Binding var isPresented: Bool @State var uploadNew: Bool = true @State private var image: PhotosPickerItem? = nil @StateObject private var prepDuration: Duration = Duration() @StateObject private var cookDuration: Duration = Duration() @StateObject private var totalDuration: Duration = Duration() @State private var searchText: String = "" @State private var keywords: [String] = [] @State private var keywordSuggestions: [String] = [] @State private var alertMessage: ErrorMessages = .GENERIC @State private var presentAlert: Bool = false @State private var waitingForUpload: Bool = false var body: some View { NavigationStack { VStack { HStack { Button() { isPresented = false } label: { Text("Cancel") .bold() } if !uploadNew { Menu { Button { print("Delete recipe.") alertMessage = .CONFIRM_DELETE presentAlert = true } label: { Image(systemName: "trash") .foregroundStyle(.red) Text("Delete recipe") .foregroundStyle(.red) } } label: { Image(systemName: "ellipsis.circle") .font(.title3) .padding() } } Spacer() Button() { if uploadNew { uploadNewRecipe() } else { uploadEditedRecipe() } } label: { Text("Upload") .bold() } }.padding() HStack { Text(recipe.name == "" ? LocalizedStringKey("New recipe") : LocalizedStringKey(recipe.name)) .font(.title) .bold() .padding() Spacer() } Form { TextField("Title", text: $recipe.name) 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: viewModel.categories.map({ category in category.name == "*" ? "Other" : category.name }), selection: $recipe.recipeCategory) } NavigationLink("Keywords") { KeywordPickerView( title: "Keywords", searchSuggestions: keywordSuggestions, selection: $keywords ) } } footer: { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(keywords, id: \.self) { keyword in Text(keyword) } } } } Section() { Picker("Servings:", selection: $recipe.recipeYield) { ForEach(0..<99, id: \.self) { i in Text("\(i)").tag(i) } } .pickerStyle(.menu) DurationPicker(title: LocalizedStringKey("Preparation duration:"), duration: prepDuration) DurationPicker(title: LocalizedStringKey("Cooking duration:"), duration: cookDuration) DurationPicker(title: LocalizedStringKey("Total duration:"), duration: totalDuration) } EditableListSection(title: LocalizedStringKey("Ingredients"), items: $recipe.recipeIngredient) EditableListSection(title: LocalizedStringKey("Tools"), items: $recipe.tool) EditableListSection(title: LocalizedStringKey("Instructions"), items: $recipe.recipeInstructions) } } } .task { self.keywordSuggestions = await viewModel.getKeywords() } .onAppear { if uploadNew { return } if let prepTime = recipe.prepTime { prepDuration.initFromPT(prepTime) } if let cookTime = recipe.cookTime { cookDuration.initFromPT(cookTime) } if let totalTime = recipe.totalTime { totalDuration.initFromPT(totalTime) } for keyword in self.recipe.keywords.components(separatedBy: ",") { keywords.append(keyword) } } .alert(alertMessage.localizedTitle, isPresented: $presentAlert) { switch alertMessage { case .CONFIRM_DELETE: Button("Cancel", role: .cancel) { } Button("Delete", role: .destructive) { deleteRecipe() } default: Button("Ok", role: .cancel) { } } } message: { Text(alertMessage.localizedDescription) } } func createRecipe() { self.recipe.prepTime = prepDuration.format() self.recipe.cookTime = cookDuration.format() self.recipe.totalTime = totalDuration.format() if !self.keywords.isEmpty { self.recipe.keywords = self.keywords.joined(separator: ",") } } func recipeValid() -> Bool { // Check if the recipe has a name if recipe.name == "" { self.alertMessage = .NO_TITLE self.presentAlert = true return false } // Check if the recipe has a unique name for recipeList in viewModel.recipes.values { for r in recipeList { if r.name .replacingOccurrences(of: " ", with: "") .lowercased() == recipe.name .replacingOccurrences(of: " ", with: "") .lowercased() { self.alertMessage = .DUPLICATE self.presentAlert = true return false } } } return true } func uploadNewRecipe() { print("Uploading new recipe.") waitingForUpload = true createRecipe() guard recipeValid() else { return } 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) dismissEditView() } func uploadEditedRecipe() { waitingForUpload = true 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) dismissEditView() } 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) if let recipeIdInt = Int(recipe.id) { viewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory) } dismissEditView() } 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) DispatchQueue.main.sync { alertMessage = .CUSTOM(title: "Error.", description: LocalizedStringKey(stringLiteral: error.msg)) presentAlert = true return } } catch { } } } func dismissEditView() { Task { await self.viewModel.loadCategoryList(needsUpdate: true) await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true) } self.isPresented = false } } struct EditableListSection: View { @State var title: LocalizedStringKey @Binding var items: [String] var body: some View { Section() { List { ForEach(items.indices, id: \.self) { ix in HStack(alignment: .top) { Text("\(ix+1).") .padding(.vertical, 10) TextEditor(text: $items[ix]) .multilineTextAlignment(.leading) .textFieldStyle(.plain) .padding(.vertical, 1) } } .onMove { indexSet, offset in items.move(fromOffsets: indexSet, toOffset: offset) } .onDelete { indexSet in items.remove(atOffsets: indexSet) } } HStack { Spacer() Text("Add") Button() { items.append("") } label: { Image(systemName: "plus.circle.fill") } } } header: { HStack { Text(title) Spacer() EditButton() } } } } struct DurationPicker: View { @State var title: LocalizedStringKey @ObservedObject var duration: Duration var body: some View { HStack { Text(title) Spacer() TextField("00", text: $duration.hourComponent) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) .frame(maxWidth: 40) Text(":") TextField("00", text: $duration.minuteComponent) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .frame(maxWidth: 40) } .frame(maxHeight: 40) .clipped() } } class Duration: ObservableObject { @Published var minuteComponent: String = "00" { didSet { if minuteComponent.count > 2 { minuteComponent = oldValue } else if minuteComponent.count == 1 { minuteComponent = "0\(minuteComponent)" } else if minuteComponent.count == 0 { minuteComponent = "00" } let filtered = minuteComponent.filter { $0.isNumber } if minuteComponent != filtered { minuteComponent = filtered } } } @Published var hourComponent: String = "00" { didSet { if hourComponent.count > 2 { hourComponent = oldValue } else if hourComponent.count == 1 { hourComponent = "0\(hourComponent)" } else if hourComponent.count == 0 { hourComponent = "00" } let filtered = hourComponent.filter { $0.isNumber } if hourComponent != filtered { hourComponent = filtered } } } func initFromPT(_ PTRepresentation: String) { let hourRegex = /([0-9]{1,2})H/ let minuteRegex = /([0-9]{1,2})M/ if let match = PTRepresentation.firstMatch(of: hourRegex) { self.hourComponent = String(match.1) } if let match = PTRepresentation.firstMatch(of: minuteRegex) { self.minuteComponent = String(match.1) } } func format() -> String { return "PT\(hourComponent)H\(minuteComponent)M00S" } }