From 63730732b6e6a1611db77ab2691fc605b6f43e7d Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Sat, 11 Nov 2023 12:11:13 +0100 Subject: [PATCH] Created RecipeEditViewModel to cleanup RecipeEditView --- .../project.pbxproj | 4 + .../Localizable.xcstrings | 3 + .../ViewModels/RecipeEditViewModel.swift | 193 ++++++++++++++ .../Views/MainView.swift | 8 +- .../Views/RecipeDetailView.swift | 9 +- .../Views/RecipeEditView.swift | 236 +++--------------- 6 files changed, 256 insertions(+), 197 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 7c33b45..1bcdb04 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; }; + A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; }; @@ -90,6 +91,7 @@ A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = ""; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = ""; }; + A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = ""; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -201,6 +203,7 @@ isa = PBXGroup; children = ( A70171AC2AA8EF4700064C43 /* MainViewModel.swift */, + A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -398,6 +401,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 0efe2ef..fc6fd89 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -883,6 +883,9 @@ } } } + }, + "Import" : { + }, "Import Recipe" : { diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift new file mode 100644 index 0000000..56b159f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -0,0 +1,193 @@ +// +// RecipeEditViewModel.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 11.11.23. +// + +import Foundation +import SwiftUI + +@MainActor class RecipeEditViewModel: ObservableObject { + @ObservedObject var mainViewModel: MainViewModel + @Published var isPresented: Binding + @Published var recipe: RecipeDetail = RecipeDetail() + + @Published var prepDuration: DurationComponents = DurationComponents() + @Published var cookDuration: DurationComponents = DurationComponents() + @Published var totalDuration: DurationComponents = DurationComponents() + + @Published var searchText: String = "" + @Published var keywords: [String] = [] + @Published var keywordSuggestions: [String] = [] + + @Published var showImportSection: Bool = false + @Published var importURL: String = "" + + @Published var presentAlert = false + var alertType: UserAlert = RecipeCreationError.GENERIC + var alertAction: @MainActor () -> () = {} + + var uploadNew: Bool = true + var waitingForUpload: Bool = false + + + init(mainViewModel: MainViewModel, isPresented: Binding, uploadNew: Bool) { + self.mainViewModel = mainViewModel + self.isPresented = isPresented + self.uploadNew = uploadNew + } + + init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, isPresented: Binding, uploadNew: Bool) { + self.mainViewModel = mainViewModel + self.recipe = recipeDetail + self.isPresented = isPresented + self.uploadNew = uploadNew + } + + + func createRecipe() { + self.recipe.prepTime = prepDuration.toPTString() + self.recipe.cookTime = cookDuration.toPTString() + self.recipe.totalTime = totalDuration.toPTString() + self.recipe.setKeywordsFromArray(keywords) + } + + func recipeValid() -> Bool { + // Check if the recipe has a name + if recipe.name.replacingOccurrences(of: " ", with: "") == "" { + alertType = RecipeCreationError.NO_TITLE + alertAction = {} + presentAlert = true + return false + } + // Check if the recipe has a unique name + for recipeList in mainViewModel.recipes.values { + for r in recipeList { + if r.name + .replacingOccurrences(of: " ", with: "") + .lowercased() == + recipe.name + .replacingOccurrences(of: " ", with: "") + .lowercased() + { + alertType = RecipeCreationError.DUPLICATE + alertAction = {} + 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) { + mainViewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory) + } + dismissEditView() + } + + func sendRequest(_ request: RequestWrapper) { + Task { + guard let apiController = mainViewModel.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) + // TODO: Better error handling (Show error to user!) + } catch { + + } + } + } + + func dismissEditView() { + Task { + await mainViewModel.loadCategoryList(needsUpdate: true) + await mainViewModel.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) + } + isPresented.wrappedValue = false + } + + func prepareView() { + if let prepTime = recipe.prepTime { + prepDuration.fromPTString(prepTime) + } + if let cookTime = recipe.cookTime { + cookDuration.fromPTString(cookTime) + } + if let totalTime = recipe.totalTime { + totalDuration.fromPTString(totalTime) + } + self.keywords = recipe.getKeywordsArray() + } + + func importRecipe() { + Task { + do { + let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) + if let scrapedRecipe = scrapedRecipe { + self.recipe = scrapedRecipe + prepareView() + } + if let error = error { + self.alertType = error + self.alertAction = {} + self.presentAlert = true + } + } catch { + print("Error") + } + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index a514504..c9e0b3e 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -80,7 +80,13 @@ struct MainView: View { } .tint(.nextcloudBlue) .sheet(isPresented: $showEditView) { - RecipeEditView(viewModel: viewModel, isPresented: $showEditView) + RecipeEditView(viewModel: + RecipeEditViewModel( + mainViewModel: viewModel, + isPresented: $showEditView, + uploadNew: true + ) + ) } .task { self.serverConnection = await viewModel.checkServerConnection() diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 48ed3d4..d298e87 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -95,7 +95,14 @@ struct RecipeDetailView: View { } .sheet(isPresented: $presentEditView) { if let recipeDetail = recipeDetail { - RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false) + RecipeEditView(viewModel: + RecipeEditViewModel( + mainViewModel: viewModel, + recipeDetail: recipeDetail, + isPresented: $presentEditView, + uploadNew: false + ) + ) } } .task { diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index 024d801..6432df5 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -12,43 +12,25 @@ import PhotosUI struct RecipeEditView: View { - @ObservedObject var viewModel: MainViewModel - @State var recipe: RecipeDetail = RecipeDetail() - @Binding var isPresented: Bool - @State var uploadNew: Bool = true - - @State private var presentAlert = false - @State private var alertType: UserAlert = RecipeCreationError.GENERIC - @State private var alertAction: () -> () = {} - - @StateObject private var prepDuration: DurationComponents = DurationComponents() - @StateObject private var cookDuration: DurationComponents = DurationComponents() - @StateObject private var totalDuration: DurationComponents = DurationComponents() - @State private var searchText: String = "" - @State private var keywords: [String] = [] - @State private var keywordSuggestions: [String] = [] - - @State private var importURL: String = "" - @State private var showImportSection: Bool = false - @State private var waitingForUpload: Bool = false + @ObservedObject var viewModel: RecipeEditViewModel var body: some View { NavigationStack { VStack { HStack { Button() { - isPresented = false + viewModel.isPresented.wrappedValue = false } label: { Text("Cancel") .bold() } - if !uploadNew { + if !viewModel.uploadNew { Menu { Button { print("Delete recipe.") - alertType = RecipeCreationError.CONFIRM_DELETE - alertAction = deleteRecipe - presentAlert = true + viewModel.alertType = RecipeCreationError.CONFIRM_DELETE + viewModel.alertAction = viewModel.deleteRecipe + viewModel.presentAlert = true } label: { Image(systemName: "trash") .foregroundStyle(.red) @@ -63,10 +45,10 @@ struct RecipeEditView: View { } Spacer() Button() { - if uploadNew { - uploadNewRecipe() + if viewModel.uploadNew { + viewModel.uploadNewRecipe() } else { - uploadEditedRecipe() + viewModel.uploadEditedRecipe() } } label: { Text("Upload") @@ -74,34 +56,24 @@ struct RecipeEditView: View { } }.padding() HStack { - Text(recipe.name == "" ? LocalizedStringKey("New recipe") : LocalizedStringKey(recipe.name)) + Text(viewModel.recipe.name == "" ? LocalizedStringKey("New recipe") : LocalizedStringKey(viewModel.recipe.name)) .font(.title) .bold() .padding() Spacer() } Form { - if showImportSection { + if viewModel.showImportSection { Section { - TextField("URL (e.g. example.com/recipe)", text: $importURL) + TextField("URL (e.g. example.com/recipe)", text: $viewModel.importURL) .onSubmit { - Task { - do { - let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) - if let scrapedRecipe = scrapedRecipe { - self.recipe = scrapedRecipe - prepareView() - } - if let error = error { - self.alertType = error - self.alertAction = {} - self.presentAlert = true - } - } catch { - print("Error") - } - } + viewModel.importRecipe() } + Button { + viewModel.importRecipe() + } label: { + Text("Import") + } } header: { Text("Import Recipe") } footer: { @@ -112,7 +84,7 @@ struct RecipeEditView: View { Section { Button() { withAnimation{ - showImportSection = true + viewModel.showImportSection = true } } label: { Text("Import recipe from a website") @@ -120,33 +92,33 @@ struct RecipeEditView: View { } } - TextField("Title", text: $recipe.name) + TextField("Title", text: $viewModel.recipe.name) Section { - TextEditor(text: $recipe.description) + TextEditor(text: $viewModel.recipe.description) } header: { Text("Description") } Section() { - NavigationLink(recipe.recipeCategory == "" ? "Category" : "Category: \(recipe.recipeCategory)") { + NavigationLink(viewModel.recipe.recipeCategory == "" ? "Category" : "Category: \(viewModel.recipe.recipeCategory)") { CategoryPickerView( title: "Category", - searchSuggestions: viewModel.categories.map({ category in + searchSuggestions: viewModel.mainViewModel.categories.map({ category in category.name == "*" ? "Other" : category.name }), - selection: $recipe.recipeCategory) + selection: $viewModel.recipe.recipeCategory) } NavigationLink("Keywords") { KeywordPickerView( title: "Keywords", - searchSuggestions: keywordSuggestions, - selection: $keywords + searchSuggestions: viewModel.keywordSuggestions, + selection: $viewModel.keywords ) } } footer: { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(keywords, id: \.self) { keyword in + ForEach(viewModel.keywords, id: \.self) { keyword in Text(keyword) } } @@ -154,173 +126,47 @@ struct RecipeEditView: View { } Section() { - Picker("Servings:", selection: $recipe.recipeYield) { + Picker("Servings:", selection: $viewModel.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) + DurationPicker(title: LocalizedStringKey("Preparation duration:"), duration: viewModel.prepDuration) + DurationPicker(title: LocalizedStringKey("Cooking duration:"), duration: viewModel.cookDuration) + DurationPicker(title: LocalizedStringKey("Total duration:"), duration: viewModel.totalDuration) } - EditableListSection(title: LocalizedStringKey("Ingredients"), items: $recipe.recipeIngredient) - EditableListSection(title: LocalizedStringKey("Tools"), items: $recipe.tool) - EditableListSection(title: LocalizedStringKey("Instructions"), items: $recipe.recipeInstructions) + EditableListSection(title: LocalizedStringKey("Ingredients"), items: $viewModel.recipe.recipeIngredient) + EditableListSection(title: LocalizedStringKey("Tools"), items: $viewModel.recipe.tool) + EditableListSection(title: LocalizedStringKey("Instructions"), items: $viewModel.recipe.recipeInstructions) } } } .task { - self.keywordSuggestions = await viewModel.getKeywords() + viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords() } .onAppear { - prepareView() + viewModel.prepareView() } - .alert(alertType.localizedTitle, isPresented: $presentAlert) { - ForEach(alertType.alertButtons) { buttonType in + .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { + ForEach(viewModel.alertType.alertButtons) { buttonType in if buttonType == .OK { Button(AlertButton.OK.rawValue, role: .cancel) { - alertAction() + viewModel.alertAction() } } else if buttonType == .CANCEL { Button(AlertButton.CANCEL.rawValue, role: .cancel) { } } else if buttonType == .DELETE { Button(AlertButton.DELETE.rawValue, role: .destructive) { - alertAction() + viewModel.alertAction() } } } } message: { - Text(alertType.localizedDescription) + Text(viewModel.alertType.localizedDescription) } } - - func createRecipe() { - self.recipe.prepTime = prepDuration.toPTString() - self.recipe.cookTime = cookDuration.toPTString() - self.recipe.totalTime = totalDuration.toPTString() - self.recipe.setKeywordsFromArray(keywords) - } - - func recipeValid() -> Bool { - // Check if the recipe has a name - if recipe.name.replacingOccurrences(of: " ", with: "") == "" { - alertType = RecipeCreationError.NO_TITLE - alertAction = {} - 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() - { - alertType = RecipeCreationError.DUPLICATE - alertAction = {} - 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) - // TODO: Better error handling (Show error to user!) - } catch { - - } - } - } - - func dismissEditView() { - Task { - await self.viewModel.loadCategoryList(needsUpdate: true) - await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true) - } - self.isPresented = false - } - - func prepareView() { - if let prepTime = recipe.prepTime { - prepDuration.fromPTString(prepTime) - } - if let cookTime = recipe.cookTime { - cookDuration.fromPTString(cookTime) - } - if let totalTime = recipe.totalTime { - totalDuration.fromPTString(totalTime) - } - self.keywords = recipe.getKeywordsArray() - } }