From 3563c23e29daed421615b6c8cb32c13532896205 Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Thu, 16 Nov 2023 13:13:06 +0100 Subject: [PATCH 1/4] Networking rework: simplified API calls. --- .../project.pbxproj | 36 +++ .../Data/DataModels.swift | 8 +- .../Data/UserSettings.swift | 19 ++ .../Extensions/JSONCoderExtension.swift | 4 +- .../Extensions/LoggerExtension.swift | 19 ++ .../Network/CookbookApi/ApiRequest.swift | 100 +++++++++ .../Network/CookbookApi/CookbookApi.swift | 187 ++++++++++++++++ .../Network/CookbookApi/CookbookApiV1.swift | 207 ++++++++++++++++++ .../Network/CustomError.swift | 2 + .../Network/NetworkRequests.swift | 61 +++--- .../Network/NextcloudApi/NextcloudApi.swift | 12 + .../ViewModels/MainViewModel.swift | 78 ++++++- .../ViewModels/RecipeEditViewModel.swift | 68 ++---- .../Views/Alerts.swift | 27 +++ 14 files changed, 731 insertions(+), 97 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift create mode 100644 Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift create mode 100644 Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift create mode 100644 Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift create mode 100644 Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 8ceea9b..e03853f 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -36,6 +36,11 @@ 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 */; }; + A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; }; + A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; }; + A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; }; + A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */; }; + A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.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 */; }; @@ -92,6 +97,11 @@ 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 = ""; }; + A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = ""; }; + A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = ""; }; + A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = ""; }; + A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiRequest.swift; sourceTree = ""; }; + A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.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 = ""; }; @@ -191,6 +201,8 @@ A70171B22AB211F000064C43 /* Network */ = { isa = PBXGroup; children = ( + A79AA8EE2B063B33007D25F2 /* NextcloudApi */, + A79AA8E72B062DB6007D25F2 /* CookbookApi */, A703226C2ABAF90D00D7C4ED /* APIController.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, @@ -241,6 +253,7 @@ children = ( A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, + A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -261,6 +274,24 @@ path = RecipeImport; sourceTree = ""; }; + A79AA8E72B062DB6007D25F2 /* CookbookApi */ = { + isa = PBXGroup; + children = ( + A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, + A79AA8E32B02A961007D25F2 /* CookbookApi.swift */, + A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */, + ); + path = CookbookApi; + sourceTree = ""; + }; + A79AA8EE2B063B33007D25F2 /* NextcloudApi */ = { + isa = PBXGroup; + children = ( + A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */, + ); + path = NextcloudApi; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -405,21 +436,26 @@ A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, + A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, + A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, + A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, + A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226D2ABAF90D00D7C4ED /* APIController.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 */, diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 819e556..23f0685 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -146,6 +146,9 @@ extension RecipeDetail { struct RecipeImage { + enum RecipeImageSize { + case THUMB, FULL + } var imageExists: Bool = true var thumb: UIImage? var full: UIImage? @@ -193,7 +196,4 @@ struct MetaData: Codable { } -// Networking -struct ServerMessage: Decodable { - let msg: String -} + diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index eb98939..c721b28 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -13,12 +13,20 @@ class UserSettings: ObservableObject { @Published var username: String { didSet { UserDefaults.standard.set(username, forKey: "username") + self.authString = setAuthString() } } @Published var token: String { didSet { UserDefaults.standard.set(token, forKey: "token") + self.authString = setAuthString() + } + } + + @Published var authString: String { + didSet { + UserDefaults.standard.set(authString, forKey: "authString") } } @@ -55,10 +63,21 @@ class UserSettings: ObservableObject { init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" + self.authString = UserDefaults.standard.object(forKey: "authString") as? String ?? "" self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? "" self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue self.downloadRecipes = UserDefaults.standard.object(forKey: "downloadRecipes") as? Bool ?? false } + + func setAuthString() -> String { + if token != "" && username != "" { + let loginString = "\(self.username):\(self.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + return loginData.base64EncodedString() + } else { + return "" + } + } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift index 89b8498..6218642 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift @@ -11,11 +11,9 @@ extension JSONDecoder { static func safeDecode(_ data: Data) -> T? { let decoder = JSONDecoder() do { - print("Decoding type ", T.self, " ...") return try decoder.decode(T.self, from: data) } catch (let error) { - print("JSONDecoder - safeDecode(): Failed to decode data.") - print("Error: ", error) + print(error) return nil } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift new file mode 100644 index 0000000..65c3999 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift @@ -0,0 +1,19 @@ +// +// Logger.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 13.11.23. +// + +import Foundation +import OSLog + +extension Logger { + private static var subsystem = Bundle.main.bundleIdentifier! + + /// UI related logging + static let view = Logger(subsystem: subsystem, category: "view") + + /// Network related logging + static let network = Logger(subsystem: subsystem, category: "network") +} diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift new file mode 100644 index 0000000..bac5b58 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift @@ -0,0 +1,100 @@ +// +// ApiRequest.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation +import OSLog + +struct ApiRequest { + /// The server address, e.g. https://example.com + let serverAddress: String + let path: String + let method: RequestMethod + let authString: String? + let headerFields: [HeaderField] + let body: Data? + + /// The path to the Cookbook application on the nextcloud server. + let cookbookPath = "/index.php/apps/cookbook" + + init( + serverAdress: String, + path: String, + method: RequestMethod, + authString: String? = nil, + headerFields: [HeaderField] = [], + body: Data? = nil + ) { + self.serverAddress = serverAdress + self.method = method + self.path = path + self.headerFields = headerFields + self.authString = authString + self.body = body + } + + func send() async -> (Data?, NetworkError?) { + Logger.network.debug("\(method.rawValue) \(path) sending ...") + + // Prepare URL + let urlString = serverAddress + cookbookPath + path + Logger.network.debug("Full path: \(urlString)") + guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) } + guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) } + + // Create URL request + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + // Set authentication string, if needed + if let authString = authString { + request.setValue( + "Basic \(authString)", + forHTTPHeaderField: "Authorization" + ) + } + + // Set other header fields + for headerField in headerFields { + request.setValue( + headerField.getValue(), + forHTTPHeaderField: headerField.getField() + ) + } + + // Set http body + if let body = body { + request.httpBody = body + } + + // Wait for and return data and (decoded) response + var data: Data? = nil + var response: URLResponse? = nil + do { + (data, response) = try await URLSession.shared.data(for: request) + Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") + return (data, nil) + } catch { + let error = decodeURLResponse(response: response as? HTTPURLResponse) + Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") + return (nil, error) + } + } + + private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { + guard let response = response else { + return NetworkError.unknownError + } + switch response.statusCode { + case 200...299: return (nil) + case 300...399: return (NetworkError.redirectionError) + case 400...499: return (NetworkError.clientError) + case 500...599: return (NetworkError.serverError) + case 600: return (NetworkError.invalidRequest) + default: return (NetworkError.unknownError) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift new file mode 100644 index 0000000..37a070a --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -0,0 +1,187 @@ +// +// CookbookApi.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 13.11.23. +// + +import Foundation +import OSLog +import UIKit + + +protocol CookbookApi { + /// Not implemented yet. + static func importRecipe( + from serverAdress: String, + auth: String, + data: Data + ) async -> (NetworkError?) + + /// Get either the full image or a thumbnail sized version. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The according recipe id. + /// - size: The size of the image. + /// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil. + static func getImage( + from serverAdress: String, + auth: String, + id: Int, + size: RecipeImage.RecipeImageSize + ) async -> (UIImage?, NetworkError?) + + /// Get all recipes. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of all recipes. + static func getRecipes( + from serverAdress: String, + auth: String + ) async -> ([Recipe]?, NetworkError?) + + /// Create a new recipe. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func createRecipe( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Get the recipe with the specified id. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: The recipe if it exists. A NetworkError if the request fails. + static func getRecipe( + from serverAdress: String, + auth: String, id: Int + ) async -> (RecipeDetail?, NetworkError?) + + /// Update an existing recipe with new entries. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func updateRecipe( + from serverAdress: String, + auth: String, id: Int + ) async -> (NetworkError?) + + /// Delete the recipe with the specified id. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func deleteRecipe( + from serverAdress: String, + auth: String, + id: Int + ) async -> (NetworkError?) + + /// Get all categories. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of categories. A NetworkError if the request fails. + static func getCategories( + from serverAdress: String, + auth: String + ) async -> ([String]?, NetworkError?) + + /// Get all recipes of a specified category. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - categoryName: The category name. + /// - Returns: A list of recipes. A NetworkError if the request fails. + static func getCategory( + from serverAdress: String, + auth: String, + named categoryName: String + ) async -> ([Recipe]?, NetworkError?) + + /// Rename an existing category. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - categoryName: The name of the category to be renamed. + /// - newName: The new category name. + /// - Returns: A NetworkError if the request fails. + static func renameCategory( + from serverAdress: String, + auth: String, + named categoryName: String, + newName: String + ) async -> (NetworkError?) + + /// Get all keywords/tags. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of tag strings. A NetworkError if the request fails. + static func getTags( + from serverAdress: String, + auth: String + ) async -> ([String]?, NetworkError?) + + /// Get all recipes tagged with the specified keyword. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - keyword: The keyword. + /// - Returns: A list of recipes tagged with the specified keyword. A NetworkError if the request fails. + static func getRecipesTagged( + from serverAdress: String, + auth: String, + keyword: String + ) async -> ([Recipe]?, NetworkError?) + + /// Get the servers api version. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A NetworkError if the request fails. + static func getApiVersion( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Trigger a reindexing action on the server. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func postReindex( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Get the current configuration of the Cookbook server application. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func getConfig( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Set the current configuration of the Cookbook server application. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func postConfig( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) +} + diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift new file mode 100644 index 0000000..e1dadc6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -0,0 +1,207 @@ +// +// CookbookApiV1.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation +import UIKit + + +class CookbookApiV1: CookbookApi { + static func importRecipe(from serverAdress: String, auth: String, data: Data) async -> (NetworkError?) { + return .unknownError + } + + static func getImage(from serverAdress: String, auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) { + let imageSize = (size == .FULL ? "full" : "thumb") + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)/image?size=\(imageSize)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (UIImage(data: data), error) + } + + static func getRecipes(from serverAdress: String, auth: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func createRecipe(from serverAdress: String, auth: String) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes", + method: .POST, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return nil + } else if let dict = json as? [String: Any] { + return .serverError + } + } catch { + return .decodingFailed + } + return nil + } + + static func getRecipe(from serverAdress: String, auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func updateRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + method: .PUT, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return nil + } else if let dict = json as? [String: Any] { + return .serverError + } + } catch { + return .decodingFailed + } + return nil + } + + static func deleteRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + method: .DELETE, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + return nil + } + + static func getCategories(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/categories", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getCategory(from serverAdress: String, auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/category/\(categoryName)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func renameCategory(from serverAdress: String, auth: String, named categoryName: String, newName: String) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/category/\(categoryName)", + method: .PUT, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + return nil + } + + static func getTags(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/keywords", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getRecipesTagged(from serverAdress: String, auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/tags/\(keyword)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getApiVersion(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func postReindex(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func getConfig(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func postConfig(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CustomError.swift b/Nextcloud Cookbook iOS Client/Network/CustomError.swift index c3f8867..e9522fc 100644 --- a/Nextcloud Cookbook iOS Client/Network/CustomError.swift +++ b/Nextcloud Cookbook iOS Client/Network/CustomError.swift @@ -18,6 +18,7 @@ public enum NetworkError: String, Error { case missingUrl = "Missing URL." case parametersNil = "Parameters are nil." case encodingFailed = "Parameter encoding failed." + case decodingFailed = "Data decoding failed." case redirectionError = "Redirection error" case clientError = "Client error" case serverError = "Server error" @@ -25,3 +26,4 @@ public enum NetworkError: String, Error { case unknownError = "Unknown error" case dataError = "Invalid data error." } + diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift index 690b435..f782a7f 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift @@ -14,35 +14,7 @@ enum RequestMethod: String { DELETE = "DELETE" } -enum RequestPath { - case CATEGORIES, - RECIPE_LIST(categoryName: String), - RECIPE_DETAIL(recipeId: Int), - NEW_RECIPE, - IMAGE(recipeId: Int, thumb: Bool), - CONFIG, - KEYWORDS - - case LOGINV2REQ, - CUSTOM(path: String), - NONE - - var stringValue: String { - switch self { - case .CATEGORIES: return "categories" - case .RECIPE_LIST(categoryName: let name): return "category/\(name)" - case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" - case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" - case .NEW_RECIPE: return "recipes" - case .CONFIG: return "config" - case .KEYWORDS: return "keywords" - - case .LOGINV2REQ: return "/index.php/login/v2" - case .CUSTOM(path: let path): return path - case .NONE: return "" - } - } -} + enum ContentType: String { case JSON = "application/json", @@ -75,6 +47,37 @@ struct HeaderField { } } + +enum RequestPath { + case CATEGORIES, + RECIPE_LIST(categoryName: String), + RECIPE_DETAIL(recipeId: Int), + NEW_RECIPE, + IMAGE(recipeId: Int, thumb: Bool), + CONFIG, + KEYWORDS + + case LOGINV2REQ, + CUSTOM(path: String), + NONE + + var stringValue: String { + switch self { + case .CATEGORIES: return "categories" + case .RECIPE_LIST(categoryName: let name): return "category/\(name)" + case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" + case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" + case .NEW_RECIPE: return "recipes" + case .CONFIG: return "config" + case .KEYWORDS: return "keywords" + + case .LOGINV2REQ: return "/index.php/login/v2" + case .CUSTOM(path: let path): return path + case .NONE: return "" + } + } +} + struct RequestWrapper { private let _method: RequestMethod private let _path: RequestPath diff --git a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift new file mode 100644 index 0000000..d5caf9d --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift @@ -0,0 +1,12 @@ +// +// NextcloudApi.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation + +class NextcloudApi { + +} diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index d165993..6f0a03b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -15,6 +15,7 @@ import UIKit @Published var recipes: [String: [Recipe]] = [:] private var recipeDetails: [Int: RecipeDetail] = [:] private var imageCache: [Int: RecipeImage] = [:] + private var requestQueue: [RequestWrapper] = [] let dataStore: DataStore var apiController: APIController? = nil @@ -206,17 +207,34 @@ import UIKit self.recipes = [:] self.imageCache = [:] self.recipeDetails = [:] + self.requestQueue = [] } } - func deleteRecipe(withId id: Int, categoryName: String) { + func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + let request = RequestWrapper.customRequest( + method: .DELETE, + path: .RECIPE_DETAIL(recipeId: id), + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true) + ] + ) + let path = "recipe\(id).data" dataStore.delete(path: path) - guard recipes[categoryName] != nil else { return } - recipes[categoryName]!.removeAll(where: { recipe in - recipe.recipe_id == id ? true : false - }) - recipeDetails.removeValue(forKey: id) + if recipes[categoryName] != nil { + recipes[categoryName]!.removeAll(where: { recipe in + recipe.recipe_id == id ? true : false + }) + recipeDetails.removeValue(forKey: id) + } + if await sendRequest(request) { + return .REQUEST_SUCCESS + } else { + requestQueue.append(request) + return .REQUEST_DELAYED + } } func checkServerConnection() async -> Bool { @@ -234,6 +252,54 @@ import UIKit } return true } + + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + var path: RequestPath? = nil + if createNew { + path = .NEW_RECIPE + } else if let recipeId = Int(recipeDetail.id) { + path = .RECIPE_DETAIL(recipeId: recipeId) + } + + guard let path = path else { return .REQUEST_DROPPED } + + let request = RequestWrapper.customRequest( + method: createNew ? .POST : .PUT, + path: path, + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true), + HeaderField.contentType(value: .JSON) + ], + body: JSONEncoder.safeEncode(recipeDetail) + ) + + if await sendRequest(request) { + return .REQUEST_SUCCESS + } else { + requestQueue.append(request) + return .REQUEST_DELAYED + } + } + + func sendRequest(_ request: RequestWrapper) async -> Bool { + guard let apiController = apiController else { return false } + let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) + guard let data = data else { return false } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + if let recipeId = json as? Int { + return true + } else if let message = json as? [String : Any] { + print("Server message: ", message["msg"] ?? "-") + return false + } + // TODO: Better error handling (Show error to user!) + } catch { + print("Could not decode server response") + } + return false + } } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index 56b159f..c4685cb 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -82,74 +82,32 @@ import SwiftUI return true } - func uploadNewRecipe() { + func uploadNewRecipe() async -> RequestAlert { 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() + guard recipeValid() else { return .REQUEST_DROPPED } + + return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true) } - func uploadEditedRecipe() { + func uploadEditedRecipe() async -> RequestAlert { waitingForUpload = true print("Uploading changed recipe.") - guard let recipeId = Int(recipe.id) else { return } + guard let recipeId = Int(recipe.id) else { return .REQUEST_DROPPED } 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() + + return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false) } - 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) + func deleteRecipe() async -> RequestAlert { + guard let id = Int(recipe.id) else { + return .REQUEST_DROPPED } - dismissEditView() + return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory) } - 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 { diff --git a/Nextcloud Cookbook iOS Client/Views/Alerts.swift b/Nextcloud Cookbook iOS Client/Views/Alerts.swift index 90c9ea2..41f7873 100644 --- a/Nextcloud Cookbook iOS Client/Views/Alerts.swift +++ b/Nextcloud Cookbook iOS Client/Views/Alerts.swift @@ -109,3 +109,30 @@ enum RecipeImportError: UserAlert { return [.OK] } } + + +enum RequestAlert: UserAlert { + case REQUEST_DELAYED, + REQUEST_DROPPED, + REQUEST_SUCCESS + + var localizedDescription: LocalizedStringKey { + switch self { + case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection." + case .REQUEST_DROPPED: return "Unable to complete action." + case .REQUEST_SUCCESS: return "Action completed." + } + } + + var localizedTitle: LocalizedStringKey { + switch self { + case .REQUEST_DELAYED: return "Action delayed" + case .REQUEST_DROPPED: return "Error" + case .REQUEST_SUCCESS: return "Success" + } + } + + var alertButtons: [AlertButton] { + return [.OK] + } +} From 23e1a665df5991ccfbe3c824dbd35fe837c2d875 Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Thu, 16 Nov 2023 17:53:30 +0100 Subject: [PATCH 2/4] Simpler api endpoints are now integrated into the MainViewModel --- .../Data/UserSettings.swift | 10 +- .../Localizable.xcstrings | 18 ++ .../Network/CookbookApi/ApiRequest.swift | 8 +- .../Network/CookbookApi/CookbookApi.swift | 8 +- .../Network/CookbookApi/CookbookApiV1.swift | 11 +- .../Nextcloud_Cookbook_iOS_ClientApp.swift | 6 +- .../ViewModels/MainViewModel.swift | 224 +++++++++++++++--- .../ViewModels/RecipeEditViewModel.swift | 12 +- .../Views/CategoryDetailView.swift | 23 +- .../Views/MainView.swift | 8 +- .../Views/RecipeCardView.swift | 4 +- .../Views/RecipeDetailView.swift | 8 +- .../Views/RecipeEditView.swift | 18 +- 13 files changed, 284 insertions(+), 74 deletions(-) diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index c721b28..62b950b 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -13,14 +13,12 @@ class UserSettings: ObservableObject { @Published var username: String { didSet { UserDefaults.standard.set(username, forKey: "username") - self.authString = setAuthString() } } @Published var token: String { didSet { UserDefaults.standard.set(token, forKey: "token") - self.authString = setAuthString() } } @@ -69,6 +67,14 @@ class UserSettings: ObservableObject { self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue self.downloadRecipes = UserDefaults.standard.object(forKey: "downloadRecipes") as? Bool ?? false + + if authString == "" { + if token != "" && username != "" { + let loginString = "\(self.username):\(self.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + authString = loginData.base64EncodedString() + } + } } func setAuthString() -> String { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 5cb380f..5c125ed 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -292,6 +292,12 @@ } } } + }, + "Action completed." : { + + }, + "Action delayed" : { + }, "Add" : { "localizations" : { @@ -622,6 +628,9 @@ } } } + }, + "Could not establish a connection to the server. The action will be retried upon reconnection." : { + }, "Delete" : { "localizations" : { @@ -864,6 +873,9 @@ } } } + }, + "Error" : { + }, "Error." : { "localizations" : { @@ -1860,6 +1872,9 @@ } } } + }, + "Success" : { + }, "Support" : { "localizations" : { @@ -2080,6 +2095,9 @@ } } } + }, + "Unable to complete action." : { + }, "Unable to connect to server." : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift index bac5b58..ea6f272 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift @@ -40,8 +40,9 @@ struct ApiRequest { Logger.network.debug("\(method.rawValue) \(path) sending ...") // Prepare URL - let urlString = serverAddress + cookbookPath + path - Logger.network.debug("Full path: \(urlString)") + let urlString = "https://" + serverAddress + cookbookPath + path + print("Full path: \(urlString)") + //Logger.network.debug("Full path: \(urlString)") guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) } guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) } @@ -76,6 +77,9 @@ struct ApiRequest { do { (data, response) = try await URLSession.shared.data(for: request) Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") + if let data = data { + print(data, String(data: data, encoding: .utf8)) + } return (data, nil) } catch { let error = decodeURLResponse(response: response as? HTTPURLResponse) diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index 37a070a..bb81234 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -49,7 +49,8 @@ protocol CookbookApi { /// - Returns: A NetworkError if the request fails. Nil otherwise. static func createRecipe( from serverAdress: String, - auth: String + auth: String, + recipe: RecipeDetail ) async -> (NetworkError?) /// Get the recipe with the specified id. @@ -94,7 +95,7 @@ protocol CookbookApi { static func getCategories( from serverAdress: String, auth: String - ) async -> ([String]?, NetworkError?) + ) async -> ([Category]?, NetworkError?) /// Get all recipes of a specified category. /// - Parameters: @@ -185,3 +186,6 @@ protocol CookbookApi { ) async -> (NetworkError?) } + + + diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index e1dadc6..2c473bc 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -43,13 +43,18 @@ class CookbookApiV1: CookbookApi { return (JSONDecoder.safeDecode(data), nil) } - static func createRecipe(from serverAdress: String, auth: String) async -> (NetworkError?) { + static func createRecipe(from serverAdress: String, auth: String, recipe: RecipeDetail) async -> (NetworkError?) { + guard let recipeData = JSONEncoder.safeEncode(recipe) else { + return .dataError + } + let request = ApiRequest( serverAdress: serverAdress, path: "/api/v1/recipes", method: .POST, authString: auth, - headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)], + body: recipeData ) let (data, error) = await request.send() @@ -119,7 +124,7 @@ class CookbookApiV1: CookbookApi { return nil } - static func getCategories(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) { + static func getCategories(from serverAdress: String, auth: String) async -> ([Category]?, NetworkError?) { let request = ApiRequest( serverAdress: serverAdress, path: "/api/v1/categories", diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index c6f45b2..25a31dd 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -10,7 +10,6 @@ import SwiftUI @main struct Nextcloud_Cookbook_iOS_ClientApp: App { @StateObject var userSettings = UserSettings() - @StateObject var mainViewModel = MainViewModel() var body: some Scene { WindowGroup { @@ -18,10 +17,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App { if userSettings.onboarding { OnboardingView(userSettings: userSettings) } else { - MainView(viewModel: mainViewModel, userSettings: userSettings) - .onAppear { - mainViewModel.apiController = APIController(userSettings: userSettings) - } + MainView(userSettings: userSettings) } } .transition(.slide) diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index 6f0a03b..b083e96 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -11,33 +11,30 @@ import UIKit @MainActor class MainViewModel: ObservableObject { + @AppStorage("authString") var authString = "" + @AppStorage("serverAddress") var serverAdress = "" + let api: CookbookApi.Type + @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] - private var recipeDetails: [Int: RecipeDetail] = [:] + @Published var recipeDetails: [Int: RecipeDetail] = [:] private var imageCache: [Int: RecipeImage] = [:] private var requestQueue: [RequestWrapper] = [] + private var serverConnection: Bool = false let dataStore: DataStore - var apiController: APIController? = nil - /// The path of an image in storage - private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in - return "image\(recipeId)_\(thumb ? "thumb" : "full")" - } - /// The path of an image on the server - private var networkImagePath: (Int, Bool) -> (String) = { recipeId, thumb in - return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" - } - - init() { + init(apiVersion api: CookbookApi.Type = CookbookApiV1.self) { + print("Created MainViewModel") + self.api = api self.dataStore = DataStore() } /// Try to load the category list from store or the server. /// - Parameters /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - func loadCategoryList(needsUpdate: Bool = false) async { + /*func loadCategoryList(needsUpdate: Bool = false) async { if let categoryList: [Category] = await loadObject( localPath: "categories.data", networkPath: .CATEGORIES, @@ -46,6 +43,23 @@ import UIKit self.categories = categoryList } print(self.categories) + }*/ + + func loadCategories() async { + let (categories, _) = await api.getCategories( + from: serverAdress, + auth: authString + ) + if let categories = categories { + self.categories = categories + await saveLocal(categories, path: "categories.data") + serverConnection = true + } else { + if let categories: [Category] = await loadLocal(path: "categories.data") { + self.categories = categories + } + serverConnection = false + } } /// Try to load the recipe list from store or the server. @@ -53,7 +67,7 @@ import UIKit /// - Parameters /// - categoryName: The name of the category containing the requested list of recipes. /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { + /*func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { let categoryString = categoryName == "*" ? "_" : categoryName if let recipeList: [Recipe] = await loadObject( localPath: "category_\(categoryString).data", @@ -64,9 +78,24 @@ import UIKit print(recipeList) } + }*/ + func getCategory(named name: String) async { + let categoryString = name == "*" ? "_" : name + let (recipes, _) = await api.getCategory( + from: serverAdress, + auth: authString, + named: name + ) + if let recipes = recipes { + self.recipes[name] = recipes + } else { + if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { + self.recipes[name] = recipes + } + } } - func getAllRecipes() async -> [Recipe] { + /*func getAllRecipes() async -> [Recipe] { var allRecipes: [Recipe] = [] for category in categories { await loadRecipeList(categoryName: category.name) @@ -77,6 +106,26 @@ import UIKit return allRecipes.sorted(by: { $0.name < $1.name }) + }*/ + func getRecipes() async -> [Recipe] { + let (recipes, error) = await api.getRecipes( + from: serverAdress, + auth: authString + ) + if let recipes = recipes { + return recipes + } else if let error = error { + print(error) + } + var allRecipes: [Recipe] = [] + for category in categories { + if let recipeArray = self.recipes[category.name] { + allRecipes.append(contentsOf: recipeArray) + } + } + return allRecipes.sorted(by: { + $0.name < $1.name + }) } /// Try to load the recipe details from cache. If not found, try to load from store or the server. @@ -84,7 +133,7 @@ import UIKit /// - recipeId: The id of the recipe. /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from cache/store first. /// - Returns: RecipeDetail struct. If not found locally, and unable to load from server, a RecipeDetail struct containing an error message. - func loadRecipeDetail(recipeId: Int, needsUpdate: Bool = false) async -> RecipeDetail { + /*func loadRecipeDetail(recipeId: Int, needsUpdate: Bool = false) async -> RecipeDetail { if !needsUpdate { if let recipeDetail = recipeDetails[recipeId] { return recipeDetail @@ -99,19 +148,46 @@ import UIKit return recipeDetail } return RecipeDetail.error + }*/ + func getRecipe(id: Int) async -> RecipeDetail { + let (recipe, error) = await api.getRecipe( + from: serverAdress, + auth: authString, + id: id + ) + if let recipe = recipe { + return recipe + } else if let error = error { + print(error) + } + guard let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") else { + return RecipeDetail.error + } + return recipe } func downloadAllRecipes() async { for category in categories { - await loadRecipeList(categoryName: category.name, needsUpdate: true) + await getCategory(named: category.name) guard let recipeList = recipes[category.name] else { continue } for recipe in recipeList { - let _ = await loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - let _ = await loadImage(recipeId: recipe.recipe_id, thumb: true) + let recipeDetail = await getRecipe(id: recipe.recipe_id) + await saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") + + let thumbnail = await getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true) + guard let thumbnail = thumbnail else { continue } + guard let thumbnailData = thumbnail.pngData() else { continue } + await saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") + + let image = await getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true) + guard let image = image else { continue } + guard let imageData = image.pngData() else { continue } + await saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") } } } + /// Check if recipeDetail is stored locally, either in cache or on disk /// - Parameters /// - recipeId: The id of a recipe. @@ -132,7 +208,7 @@ import UIKit /// - full: If true, load the full resolution image. Otherwise, load a thumbnail-sized image. /// - needsUpdate: Determines wether the image should be loaded directly from the server, or if it should be loaded from cache/store first. /// - Returns: The image if found locally or on the server, otherwise nil. - func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { + /*func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { 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 { @@ -151,6 +227,9 @@ import UIKit } } + + + // Check imageExists flag to detect if we attempted to load a non-existing image before. // This allows us to avoid sending requests to the server if we already know the recipe has no image. if imageCache[recipeId] != nil { @@ -188,9 +267,22 @@ import UIKit } imageCache[recipeId] = RecipeImage(imageExists: false) return nil + }*/ + func getImage(id: Int, size: RecipeImage.RecipeImageSize, needsUpdate: Bool) async -> UIImage? { + if !needsUpdate, let image = await imageFromStore(id: id, size: size) { + return image + } + let (image, _) = await api.getImage( + from: serverAdress, + auth: authString, + id: id, + size: size + ) + if let image = image { return image } + return await imageFromStore(id: id, size: size) } - func getKeywords() async -> [String] { + /*func getKeywords() async -> [String] { if let keywords: [RecipeKeyword] = await self.loadObject( localPath: "keywords.data", networkPath: .KEYWORDS, @@ -199,6 +291,21 @@ import UIKit return keywords.map { $0.name } } return [] + }*/ + func getKeywords() async -> [String] { + let (tags, error) = await api.getTags( + from: serverAdress, + auth: authString + ) + if let tags = tags { + return tags + } else if let error = error { + print(error) + } + if let keywords: [String] = await loadLocal(path: "keywords.data") { + return keywords + } + return [] } func deleteAllData() { @@ -211,7 +318,7 @@ import UIKit } } - func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + /*func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { let request = RequestWrapper.customRequest( method: .DELETE, path: .RECIPE_DETAIL(recipeId: id), @@ -235,9 +342,29 @@ import UIKit requestQueue.append(request) return .REQUEST_DELAYED } + }*/ + func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + let (error) = await api.deleteRecipe( + from: serverAdress, + auth: authString, + id: id + ) + + if let error = error { + return .REQUEST_DROPPED + } + let path = "recipe\(id).data" + dataStore.delete(path: path) + if recipes[categoryName] != nil { + recipes[categoryName]!.removeAll(where: { recipe in + recipe.recipe_id == id ? true : false + }) + recipeDetails.removeValue(forKey: id) + } + return .REQUEST_SUCCESS } - func checkServerConnection() async -> Bool { + /*func checkServerConnection() async -> Bool { guard let apiController = apiController else { return false } let req = RequestWrapper.customRequest( method: .GET, @@ -251,9 +378,21 @@ import UIKit return false } return true + }*/ + func checkServerConnection() async -> Bool { + let (categories, _) = await api.getCategories( + from: serverAdress, + auth: authString + ) + if let categories = categories { + self.categories = categories + await saveLocal(categories, path: "categories.data") + return true + } + return false } - func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + /*func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { var path: RequestPath? = nil if createNew { path = .NEW_RECIPE @@ -280,9 +419,21 @@ import UIKit requestQueue.append(request) return .REQUEST_DELAYED } + }*/ + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + let error = await api.createRecipe( + from: serverAdress, + auth: authString, + recipe: recipeDetail + ) + + if let error = error { + return .REQUEST_DROPPED + } + return .REQUEST_SUCCESS } - func sendRequest(_ request: RequestWrapper) async -> Bool { + /*func sendRequest(_ request: RequestWrapper) async -> Bool { guard let apiController = apiController else { return false } let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) guard let data = data else { return false } @@ -299,14 +450,27 @@ import UIKit print("Could not decode server response") } return false - } + }*/ } extension MainViewModel { - private func loadObject(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { + func loadLocal(path: String) async -> T? { + do { + return try await dataStore.load(fromPath: path) + } catch (let error) { + print(error) + return nil + } + } + + func saveLocal(_ object: T, path: String) async { + guard let data = JSONEncoder.safeEncode(object) else { return } + await dataStore.save(data: data, toPath: path) + } + /*private func loadObject(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { do { if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) { print("Data found locally.") @@ -350,10 +514,10 @@ extension MainViewModel { } return nil } - - private func imageFromStore(recipeId: Int, thumb: Bool) async -> UIImage? { + */ + private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { do { - let localPath = localImagePath(recipeId, thumb) + let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")" if let data: String = try await dataStore.load(fromPath: localPath) { guard let dataDecoded = Data(base64Encoded: data) else { return nil } let image = UIImage(data: dataDecoded) diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index c4685cb..ce38cf2 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -26,7 +26,7 @@ import SwiftUI @Published var presentAlert = false var alertType: UserAlert = RecipeCreationError.GENERIC - var alertAction: @MainActor () -> () = {} + var alertAction: @MainActor () async -> (RequestAlert) = { return .REQUEST_DROPPED } var uploadNew: Bool = true var waitingForUpload: Bool = false @@ -57,7 +57,7 @@ import SwiftUI // Check if the recipe has a name if recipe.name.replacingOccurrences(of: " ", with: "") == "" { alertType = RecipeCreationError.NO_TITLE - alertAction = {} + alertAction = {return .REQUEST_DROPPED} presentAlert = true return false } @@ -72,7 +72,7 @@ import SwiftUI .lowercased() { alertType = RecipeCreationError.DUPLICATE - alertAction = {} + alertAction = {return .REQUEST_DROPPED} presentAlert = true return false } @@ -111,8 +111,8 @@ import SwiftUI func dismissEditView() { Task { - await mainViewModel.loadCategoryList(needsUpdate: true) - await mainViewModel.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) + await mainViewModel.loadCategories() //loadCategoryList(needsUpdate: true) + await mainViewModel.getCategory(named: recipe.recipeCategory)//.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) } isPresented.wrappedValue = false } @@ -140,7 +140,7 @@ import SwiftUI } if let error = error { self.alertType = error - self.alertAction = {} + self.alertAction = {return .REQUEST_DROPPED} self.presentAlert = true } } catch { diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index 94d5ede..deef201 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -61,10 +61,10 @@ struct CategoryDetailView: View { } .searchable(text: $searchText, prompt: "Search recipes") .task { - await viewModel.loadRecipeList(categoryName: categoryName) + await viewModel.getCategory(named: categoryName)//.loadRecipeList(categoryName: categoryName) } .refreshable { - await viewModel.loadRecipeList(categoryName: categoryName, needsUpdate: true) + await viewModel.getCategory(named: categoryName)//.loadRecipeList(categoryName: categoryName, needsUpdate: true) } } @@ -79,13 +79,20 @@ struct CategoryDetailView: View { func downloadRecipes() { if let recipes = viewModel.recipes[categoryName] { - let dispatchQueue = DispatchQueue(label: "RecipeDownload", qos: .background) - dispatchQueue.async { + Task { for recipe in recipes { - Task { - let _ = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) - } + let recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id) + await viewModel.saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") + + let thumbnail = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true) + guard let thumbnail = thumbnail else { continue } + guard let thumbnailData = thumbnail.pngData() else { continue } + await viewModel.saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") + + let image = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true) + guard let image = image else { continue } + guard let imageData = image.pngData() else { continue } + await viewModel.saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") } } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index c9e0b3e..136a590 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -9,8 +9,8 @@ import SwiftUI struct MainView: View { - @ObservedObject var viewModel: MainViewModel @ObservedObject var userSettings: UserSettings + @StateObject var viewModel = MainViewModel() @State private var selectedCategory: Category? = nil @State private var showEditView: Bool = false @@ -90,7 +90,7 @@ struct MainView: View { } .task { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategoryList() + await viewModel.loadCategories()//viewModel.loadCategoryList() // Open detail view for default category if userSettings.defaultCategory != "" { if let cat = viewModel.categories.first(where: { c in @@ -105,7 +105,7 @@ struct MainView: View { } .refreshable { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategoryList(needsUpdate: true) + await viewModel.loadCategories()//loadCategoryList(needsUpdate: true) } } @@ -208,7 +208,7 @@ struct RecipeSearchView: View { .navigationTitle("Search recipe") } .task { - allRecipes = await viewModel.getAllRecipes() + allRecipes = await viewModel.getRecipes()//.getAllRecipes() } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index f5a7fd6..57e7b84 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -51,11 +51,11 @@ struct RecipeCardView: View { .clipShape(RoundedRectangle(cornerRadius: 17)) .padding(.horizontal) .task { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: false)//loadImage(recipeId: recipe.recipe_id, thumb: true) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true)//.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 0f6f2d0..cb99fbf 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -106,13 +106,13 @@ struct RecipeDetailView: View { } } .task { - recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id)//loadRecipeDetail(recipeId: recipe.recipe_id) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: false)//.loadImage(recipeId: recipe.recipe_id, thumb: false) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id)//.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true)//.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index 323d081..dd830c4 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -45,10 +45,12 @@ struct RecipeEditView: View { } Spacer() Button() { - if viewModel.uploadNew { - viewModel.uploadNewRecipe() - } else { - viewModel.uploadEditedRecipe() + Task { + if viewModel.uploadNew { + await viewModel.uploadNewRecipe() + } else { + await viewModel.uploadEditedRecipe() + } } } label: { Text("Upload") @@ -150,13 +152,17 @@ struct RecipeEditView: View { ForEach(viewModel.alertType.alertButtons) { buttonType in if buttonType == .OK { Button(AlertButton.OK.rawValue, role: .cancel) { - viewModel.alertAction() + Task { + await viewModel.alertAction() + } } } else if buttonType == .CANCEL { Button(AlertButton.CANCEL.rawValue, role: .cancel) { } } else if buttonType == .DELETE { Button(AlertButton.DELETE.rawValue, role: .destructive) { - viewModel.alertAction() + Task { + await viewModel.alertAction() + } } } } From 5e4b87b201bed4cf4026dd1ebc04d24f1167d4d5 Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:47:21 +0100 Subject: [PATCH 3/4] Better API call handling. --- .../Network/CookbookApi/CookbookApi.swift | 3 +- .../Network/CookbookApi/CookbookApiV1.swift | 10 +- .../ViewModels/MainViewModel.swift | 587 ++++++++---------- .../ViewModels/RecipeEditViewModel.swift | 4 +- .../Views/CategoryDetailView.swift | 10 +- .../Views/MainView.swift | 4 +- .../Views/RecipeCardView.swift | 4 +- .../Views/RecipeDetailView.swift | 8 +- .../Views/RecipeEditView.swift | 2 +- 9 files changed, 286 insertions(+), 346 deletions(-) diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index bb81234..6bd6f24 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -72,7 +72,8 @@ protocol CookbookApi { /// - Returns: A NetworkError if the request fails. Nil otherwise. static func updateRecipe( from serverAdress: String, - auth: String, id: Int + auth: String, + recipe: RecipeDetail ) async -> (NetworkError?) /// Delete the recipe with the specified id. diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index 2c473bc..e47e421 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -86,13 +86,17 @@ class CookbookApiV1: CookbookApi { return (JSONDecoder.safeDecode(data), nil) } - static func updateRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) { + static func updateRecipe(from serverAdress: String, auth: String, recipe: RecipeDetail) async -> (NetworkError?) { + guard let recipeData = JSONEncoder.safeEncode(recipe) else { + return .dataError + } let request = ApiRequest( serverAdress: serverAdress, - path: "/api/v1/recipes/\(id)", + path: "/api/v1/recipes/\(recipe.id)", method: .PUT, authString: auth, - headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)], + body: recipeData ) let (data, error) = await request.send() diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index b083e96..8abe2e7 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -13,17 +13,15 @@ import UIKit @MainActor class MainViewModel: ObservableObject { @AppStorage("authString") var authString = "" @AppStorage("serverAddress") var serverAdress = "" - let api: CookbookApi.Type + @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:] - private var imageCache: [Int: RecipeImage] = [:] private var requestQueue: [RequestWrapper] = [] - private var serverConnection: Bool = false - - let dataStore: DataStore + private let api: CookbookApi.Type + private let dataStore: DataStore init(apiVersion api: CookbookApi.Type = CookbookApiV1.self) { print("Created MainViewModel") @@ -31,21 +29,19 @@ import UIKit self.dataStore = DataStore() } - /// Try to load the category list from store or the server. - /// - Parameters - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - /*func loadCategoryList(needsUpdate: Bool = false) async { - if let categoryList: [Category] = await loadObject( - localPath: "categories.data", - networkPath: .CATEGORIES, - needsUpdate: needsUpdate - ) { - self.categories = categoryList - } - print(self.categories) - }*/ + enum FetchMode { + case preferLocal, preferServer, onlyLocal, onlyServer + } - func loadCategories() async { + + /** + Asynchronously loads and updates the list of categories. + + This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + */ + func getCategories() async { let (categories, _) = await api.getCategories( from: serverAdress, auth: authString @@ -53,60 +49,76 @@ import UIKit if let categories = categories { self.categories = categories await saveLocal(categories, path: "categories.data") - serverConnection = true } else { + // If there's no server connection, try loading categories from local storage if let categories: [Category] = await loadLocal(path: "categories.data") { self.categories = categories } - serverConnection = false } } - /// Try to load the recipe list from store or the server. - /// - Warning: The category named '\*' is translated into '\_' for network calls and storage requests in this function. This is necessary for the nextcloud cookbook api. - /// - Parameters - /// - categoryName: The name of the category containing the requested list of recipes. - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - /*func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { - let categoryString = categoryName == "*" ? "_" : categoryName - if let recipeList: [Recipe] = await loadObject( - localPath: "category_\(categoryString).data", - networkPath: .RECIPE_LIST(categoryName: categoryString), - needsUpdate: needsUpdate - ) { - recipes[categoryName] = recipeList - print(recipeList) - } - - }*/ - func getCategory(named name: String) async { - let categoryString = name == "*" ? "_" : name - let (recipes, _) = await api.getCategory( - from: serverAdress, - auth: authString, - named: name - ) - if let recipes = recipes { - self.recipes[name] = recipes - } else { + + /** + Fetches recipes for a specified category from either the server or local storage. + + - Parameters: + - name: The name of the category. Use "*" to fetch recipes without assigned categories. + - needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first. + + This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage. + + - Note: The category name "*" is used for all uncategorized recipes. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + */ + func getCategory(named name: String, fetchMode: FetchMode) async { + func getLocal() async -> Bool { if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { self.recipes[name] = recipes + return true } + return false + } + + func getServer() async -> Bool { + let (recipes, _) = await api.getCategory( + from: serverAdress, + auth: authString, + named: name + ) + if let recipes = recipes { + self.recipes[name] = recipes + return true + } + return false + } + + let categoryString = name == "*" ? "_" : name + switch fetchMode { + case .preferLocal: + if await getLocal() { return } + if await getServer() { return } + case .preferServer: + if await getServer() { return } + if await getLocal() { return } + case .onlyLocal: + if await getLocal() { return } + case .onlyServer: + if await getServer() { return } } } - /*func getAllRecipes() async -> [Recipe] { - var allRecipes: [Recipe] = [] - for category in categories { - await loadRecipeList(categoryName: category.name) - if let recipeArray = recipes[category.name] { - allRecipes.append(contentsOf: recipeArray) - } - } - return allRecipes.sorted(by: { - $0.name < $1.name - }) - }*/ + /** + Asynchronously retrieves all recipes either from the server or the locally cached data. + + This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded. + + Example usage: + ```swift + let recipes = await mainViewModel.getRecipes() + */ func getRecipes() async -> [Recipe] { let (recipes, error) = await api.getRecipes( from: serverAdress, @@ -128,58 +140,85 @@ import UIKit }) } - /// Try to load the recipe details from cache. If not found, try to load from store or the server. - /// - Parameters - /// - recipeId: The id of the recipe. - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from cache/store first. - /// - Returns: RecipeDetail struct. If not found locally, and unable to load from server, a RecipeDetail struct containing an error message. - /*func loadRecipeDetail(recipeId: Int, needsUpdate: Bool = false) async -> RecipeDetail { - if !needsUpdate { - if let recipeDetail = recipeDetails[recipeId] { - return recipeDetail + /** + Asynchronously retrieves a recipe detail either from the server or locally cached data. + + This function attempts to fetch a recipe detail with the specified `id` from the server using the provided `api`. If the server connection is successful, it returns the fetched recipe detail. If the server connection fails, it falls back to loading the recipe detail from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe to retrieve. + + Example usage: + ```swift + let recipeDetail = await mainViewModel.getRecipe(id: 123) + */ + func getRecipe(id: Int, fetchMode: FetchMode) async -> RecipeDetail { + func getLocal() async -> RecipeDetail? { + if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { + return recipe } + return nil } - if let recipeDetail: RecipeDetail = await loadObject( - localPath: "recipe\(recipeId).data", - networkPath: .RECIPE_DETAIL(recipeId: recipeId), - needsUpdate: needsUpdate - ) { - recipeDetails[recipeId] = recipeDetail - return recipeDetail + + func getServer() async -> RecipeDetail? { + let (recipe, error) = await api.getRecipe( + from: serverAdress, + auth: authString, + id: id + ) + if let recipe = recipe { + return recipe + } else if let error = error { + print(error) + } + return nil } - return RecipeDetail.error - }*/ - func getRecipe(id: Int) async -> RecipeDetail { - let (recipe, error) = await api.getRecipe( - from: serverAdress, - auth: authString, - id: id - ) - if let recipe = recipe { - return recipe - } else if let error = error { - print(error) + + switch fetchMode { + case .preferLocal: + if let recipe = await getLocal() { return recipe } + if let recipe = await getServer() { return recipe } + case .preferServer: + if let recipe = await getServer() { return recipe } + if let recipe = await getLocal() { return recipe } + case .onlyLocal: + if let recipe = await getLocal() { return recipe } + case .onlyServer: + if let recipe = await getServer() { return recipe } } - guard let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") else { - return RecipeDetail.error - } - return recipe + return .error } + + + + /** + Asynchronously downloads and saves details, thumbnails, and full images for all recipes. + + This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + await mainViewModel.downloadAllRecipes() + */ func downloadAllRecipes() async { for category in categories { - await getCategory(named: category.name) + await getCategory(named: category.name, fetchMode: .onlyServer) guard let recipeList = recipes[category.name] else { continue } for recipe in recipeList { - let recipeDetail = await getRecipe(id: recipe.recipe_id) + let recipeDetail = await getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer) await saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") - let thumbnail = await getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true) + let thumbnail = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .onlyServer) guard let thumbnail = thumbnail else { continue } guard let thumbnailData = thumbnail.pngData() else { continue } await saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") - let image = await getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true) + let image = await getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .onlyServer) guard let image = image else { continue } guard let imageData = image.pngData() else { continue } await saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") @@ -201,109 +240,88 @@ import UIKit return false } - - /// Try to load the recipe image from cache. If not found, try to load from store or the server. - /// - Parameters - /// - recipeId: The id of a recipe. - /// - full: If true, load the full resolution image. Otherwise, load a thumbnail-sized image. - /// - needsUpdate: Determines wether the image should be loaded directly from the server, or if it should be loaded from cache/store first. - /// - Returns: The image if found locally or on the server, otherwise nil. - /*func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { - 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 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 - } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } else { - imageCache[recipeId] = RecipeImage(imageExists: false) - return nil - } + /** + Asynchronously retrieves and returns an image for a recipe with the specified ID and size. + + This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe associated with the image. + - size: The size of the desired image (thumbnail or full). + - needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage. + + Example usage: + ```swift + let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true) + */ + func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? { + func getLocal() async -> UIImage? { + return await imageFromStore(id: id, size: size) } - - - - // Check imageExists flag to detect if we attempted to load a non-existing image before. - // This allows us to avoid sending requests to the server if we already know the recipe has no image. - if imageCache[recipeId] != nil { - guard imageCache[recipeId]!.imageExists else { return nil } + func getServer() async -> UIImage? { + let (image, _) = await api.getImage( + from: serverAdress, + auth: authString, + id: id, + size: size + ) + if let image = image { return image } + return nil } - // Try to load image from cache - print("Attempting to load image from cache ...") - if let image = imageFromCache(recipeId: recipeId, thumb: thumb) { - print("Image found in cache.") - return image + switch fetchMode { + case .preferLocal: + if let image = await getLocal() { return image } + if let image = await getServer() { return image } + case .preferServer: + if let image = await getServer() { return image } + if let image = await getLocal() { return image } + case .onlyLocal: + if let image = await getLocal() { return image } + case .onlyServer: + if let image = await getServer() { return image } } - - // Try to load from store - print("Attempting to load image from local storage ...") - if let image = await imageFromStore(recipeId: recipeId, thumb: thumb) { - print("Image found in local storage.") - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } - - // Try to load from the server. Store if successfull. - print("Attempting to load image from server ...") - 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 { - imageCache[recipeId] = RecipeImage(imageExists: false) - return nil - } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } - imageCache[recipeId] = RecipeImage(imageExists: false) return nil - }*/ - func getImage(id: Int, size: RecipeImage.RecipeImageSize, needsUpdate: Bool) async -> UIImage? { - if !needsUpdate, let image = await imageFromStore(id: id, size: size) { - return image - } - let (image, _) = await api.getImage( - from: serverAdress, - auth: authString, - id: id, - size: size - ) - if let image = image { return image } - return await imageFromStore(id: id, size: size) } - /*func getKeywords() async -> [String] { - if let keywords: [RecipeKeyword] = await self.loadObject( - localPath: "keywords.data", - networkPath: .KEYWORDS, - needsUpdate: true - ) { - return keywords.map { $0.name } + /** + Asynchronously retrieves and returns a list of keywords (tags). + + This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + let keywords = await mainViewModel.getKeywords() + */ + func getKeywords(fetchMode: FetchMode) async -> [String] { + func getLocal() async -> [String]? { + return await loadLocal(path: "keywords.data") } - return [] - }*/ - func getKeywords() async -> [String] { - let (tags, error) = await api.getTags( - from: serverAdress, - auth: authString - ) - if let tags = tags { + + func getServer() async -> [String]? { + let (tags, _) = await api.getTags( + from: serverAdress, + auth: authString + ) return tags - } else if let error = error { - print(error) } - if let keywords: [String] = await loadLocal(path: "keywords.data") { - return keywords + + switch fetchMode { + case .preferLocal: + if let keywords = await getLocal() { return keywords } + if let keywords = await getServer() { return keywords } + case .preferServer: + if let keywords = await getServer() { return keywords } + if let keywords = await getLocal() { return keywords } + case .onlyLocal: + if let keywords = await getLocal() { return keywords } + case .onlyServer: + if let keywords = await getServer() { return keywords } } return [] } @@ -312,37 +330,26 @@ import UIKit if dataStore.clearAll() { self.categories = [] self.recipes = [:] - self.imageCache = [:] self.recipeDetails = [:] self.requestQueue = [] } } - /*func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { - let request = RequestWrapper.customRequest( - method: .DELETE, - path: .RECIPE_DETAIL(recipeId: id), - headerFields: [ - HeaderField.accept(value: .JSON), - HeaderField.ocsRequest(value: true) - ] - ) - - let path = "recipe\(id).data" - dataStore.delete(path: path) - if recipes[categoryName] != nil { - recipes[categoryName]!.removeAll(where: { recipe in - recipe.recipe_id == id ? true : false - }) - recipeDetails.removeValue(forKey: id) - } - if await sendRequest(request) { - return .REQUEST_SUCCESS - } else { - requestQueue.append(request) - return .REQUEST_DELAYED - } - }*/ + /** + Asynchronously deletes a recipe with the specified ID from the server and local storage. + + This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe to delete. + - categoryName: The name of the category to which the recipe belongs. + + Example usage: + ```swift + let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts") + */ func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { let (error) = await api.deleteRecipe( from: serverAdress, @@ -364,21 +371,17 @@ import UIKit return .REQUEST_SUCCESS } - /*func checkServerConnection() async -> Bool { - guard let apiController = apiController else { return false } - let req = RequestWrapper.customRequest( - method: .GET, - path: .CONFIG, - headerFields: [ - .ocsRequest(value: true), - .accept(value: .JSON) - ] - ) - if let error = await apiController.sendRequest(req) { - return false - } - return true - }*/ + /** + Asynchronously checks the server connection by attempting to fetch categories. + + This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + let isConnected = await mainViewModel.checkServerConnection() + */ func checkServerConnection() async -> Bool { let (categories, _) = await api.getCategories( from: serverAdress, @@ -392,65 +395,41 @@ import UIKit return false } - /*func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { - var path: RequestPath? = nil - if createNew { - path = .NEW_RECIPE - } else if let recipeId = Int(recipeDetail.id) { - path = .RECIPE_DETAIL(recipeId: recipeId) - } - - guard let path = path else { return .REQUEST_DROPPED } - - let request = RequestWrapper.customRequest( - method: createNew ? .POST : .PUT, - path: path, - headerFields: [ - HeaderField.accept(value: .JSON), - HeaderField.ocsRequest(value: true), - HeaderField.contentType(value: .JSON) - ], - body: JSONEncoder.safeEncode(recipeDetail) - ) - - if await sendRequest(request) { - return .REQUEST_SUCCESS - } else { - requestQueue.append(request) - return .REQUEST_DELAYED - } - }*/ + /** + Asynchronously uploads a recipe to the server. + + This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - recipeDetail: The detailed information of the recipe to upload. + - createNew: If true, creates a new recipe on the server; otherwise, updates an existing one. + + Example usage: + ```swift + let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true) + */ func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { - let error = await api.createRecipe( - from: serverAdress, - auth: authString, - recipe: recipeDetail - ) - + var error: NetworkError? = nil + if createNew { + error = await api.createRecipe( + from: serverAdress, + auth: authString, + recipe: recipeDetail + ) + } else { + error = await api.updateRecipe( + from: serverAdress, + auth: authString, + recipe: recipeDetail + ) + } if let error = error { return .REQUEST_DROPPED } return .REQUEST_SUCCESS } - - /*func sendRequest(_ request: RequestWrapper) async -> Bool { - guard let apiController = apiController else { return false } - let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) - guard let data = data else { return false } - do { - let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) - if let recipeId = json as? Int { - return true - } else if let message = json as? [String : Any] { - print("Server message: ", message["msg"] ?? "-") - return false - } - // TODO: Better error handling (Show error to user!) - } catch { - print("Could not decode server response") - } - return false - }*/ } @@ -470,51 +449,7 @@ extension MainViewModel { guard let data = JSONEncoder.safeEncode(object) else { return } await dataStore.save(data: data, toPath: path) } - /*private func loadObject(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { - do { - if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) { - print("Data found locally.") - return data - } else { - guard let apiController = apiController else { return nil } - let request = RequestWrapper.jsonGetRequest(path: networkPath) - let (data, error): (T?, Error?) = await apiController.sendDataRequest(request) - print(error as Any) - if let data = data { - await dataStore.save(data: data, toPath: localPath) - } - return data - } - } catch { - print("An unknown error occurred.") - } - return nil - } - private func imageToCache(image: UIImage, recipeId: Int, thumb: Bool) { - if imageCache[recipeId] == nil { - imageCache[recipeId] = RecipeImage(imageExists: true) - } - if thumb { - imageCache[recipeId]!.imageExists = true - imageCache[recipeId]!.thumb = image - } else { - imageCache[recipeId]!.imageExists = true - imageCache[recipeId]!.full = image - } - } - - private func imageFromCache(recipeId: Int, thumb: Bool) -> UIImage? { - if imageCache[recipeId] != nil { - if thumb { - return imageCache[recipeId]!.thumb - } else { - return imageCache[recipeId]!.full - } - } - return nil - } - */ private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { do { let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")" diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index ce38cf2..aced57b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -111,8 +111,8 @@ import SwiftUI func dismissEditView() { Task { - await mainViewModel.loadCategories() //loadCategoryList(needsUpdate: true) - await mainViewModel.getCategory(named: recipe.recipeCategory)//.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) + await mainViewModel.getCategories() + await mainViewModel.getCategory(named: recipe.recipeCategory, fetchMode: .preferServer) } isPresented.wrappedValue = false } diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index deef201..384b05d 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -61,10 +61,10 @@ struct CategoryDetailView: View { } .searchable(text: $searchText, prompt: "Search recipes") .task { - await viewModel.getCategory(named: categoryName)//.loadRecipeList(categoryName: categoryName) + await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal) } .refreshable { - await viewModel.getCategory(named: categoryName)//.loadRecipeList(categoryName: categoryName, needsUpdate: true) + await viewModel.getCategory(named: categoryName, fetchMode: .preferServer) } } @@ -81,15 +81,15 @@ struct CategoryDetailView: View { if let recipes = viewModel.recipes[categoryName] { Task { for recipe in recipes { - let recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id) + let recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer) await viewModel.saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") - let thumbnail = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true) + let thumbnail = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .onlyServer) guard let thumbnail = thumbnail else { continue } guard let thumbnailData = thumbnail.pngData() else { continue } await viewModel.saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") - let image = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true) + let image = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .onlyServer) guard let image = image else { continue } guard let imageData = image.pngData() else { continue } await viewModel.saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 136a590..5343e3c 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -90,7 +90,7 @@ struct MainView: View { } .task { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategories()//viewModel.loadCategoryList() + await viewModel.getCategories()//viewModel.loadCategoryList() // Open detail view for default category if userSettings.defaultCategory != "" { if let cat = viewModel.categories.first(where: { c in @@ -105,7 +105,7 @@ struct MainView: View { } .refreshable { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategories()//loadCategoryList(needsUpdate: true) + await viewModel.getCategories()//loadCategoryList(needsUpdate: true) } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index 57e7b84..9db1e67 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -51,11 +51,11 @@ struct RecipeCardView: View { .clipShape(RoundedRectangle(cornerRadius: 17)) .padding(.horizontal) .task { - recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: false)//loadImage(recipeId: recipe.recipe_id, thumb: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, needsUpdate: true)//.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferServer) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index cb99fbf..cd70f58 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -106,13 +106,13 @@ struct RecipeDetailView: View { } } .task { - recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id)//loadRecipeDetail(recipeId: recipe.recipe_id) - recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: false)//.loadImage(recipeId: recipe.recipe_id, thumb: false) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferLocal)//loadRecipeDetail(recipeId: recipe.recipe_id) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferLocal)//.loadImage(recipeId: recipe.recipe_id, thumb: false) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id)//.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, needsUpdate: true)//.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index dd830c4..463d453 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -143,7 +143,7 @@ struct RecipeEditView: View { } } .task { - viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords() + viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords(fetchMode: .preferServer) } .onAppear { viewModel.prepareView() From 642c31b2af79f59ceb0664f80c5f8c0f4beef0b1 Mon Sep 17 00:00:00 2001 From: VincentM <35202538+VincentMeilinger@users.noreply.github.com> Date: Sun, 10 Dec 2023 11:00:12 +0100 Subject: [PATCH 4/4] Revert "Merge updated onboarding view" --- .../project.pbxproj | 16 +- .../Data/UserSettings.swift | 2 - .../Localizable.xcstrings | 20 - .../Network/APIController.swift | 16 +- .../Nextcloud_Cookbook_iOS_ClientApp.swift | 17 +- .../Views/MainView.swift | 8 +- .../Views/Onboarding/OnboardingView.swift | 159 -------- .../Views/Onboarding/TokenLoginView.swift | 131 ------- .../Views/Onboarding/V2LoginView.swift | 250 ------------ .../Views/OnboardingView.swift | 355 ++++++++++++++++++ 10 files changed, 371 insertions(+), 603 deletions(-) delete mode 100644 Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift delete mode 100644 Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift delete mode 100644 Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift create mode 100644 Nextcloud Cookbook iOS Client/Views/OnboardingView.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 9ffd489..e03853f 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -36,8 +36,6 @@ 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 */; }; - A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */; }; - A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */; }; A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; }; A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; }; A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; }; @@ -99,8 +97,6 @@ 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 = ""; }; - A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; - A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = ""; }; A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = ""; }; A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = ""; }; @@ -232,7 +228,7 @@ A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, - A79AA8EF2B0D0B5F007D25F2 /* Onboarding */, + A70171C82AB4CBB400064C43 /* OnboardingView.swift */, A70171CC2AB501B100064C43 /* SettingsView.swift */, A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, @@ -278,14 +274,6 @@ path = RecipeImport; sourceTree = ""; }; - A79AA8EF2B0D0B5F007D25F2 /* Onboarding */ = { - isa = PBXGroup; - children = ( - A70171C82AB4CBB400064C43 /* OnboardingView.swift */, - A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */, - A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */, - ); - path = Onboarding; A79AA8E72B062DB6007D25F2 /* CookbookApi */ = { isa = PBXGroup; children = ( @@ -462,7 +450,6 @@ A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, - A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, @@ -474,7 +461,6 @@ A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */, - A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index 1b18fbb..62b950b 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -86,6 +86,4 @@ class UserSettings: ObservableObject { return "" } } - - } diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 420696a..5c125ed 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -853,7 +853,6 @@ } }, "Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -965,9 +964,6 @@ } } } - }, - "If the login button does not open your browser, copy the following link and paste it in your browser manually:" : { - }, "If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : { "localizations" : { @@ -1238,9 +1234,6 @@ } } } - }, - "Login" : { - }, "Login failed." : { "localizations" : { @@ -1285,9 +1278,6 @@ } } } - }, - "Make sure to enter the server address in the form 'example.com'. Currently, only servers using the 'https' protocol are supported." : { - }, "Missing recipe name." : { "localizations" : { @@ -1510,7 +1500,6 @@ } }, "Open in browser" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1707,9 +1696,6 @@ } } } - }, - "Pull to refresh." : { - }, "Same as Device" : { "localizations" : { @@ -1864,9 +1850,6 @@ } } } - }, - "Show help" : { - }, "Submit" : { "localizations" : { @@ -1936,9 +1919,6 @@ } } } - }, - "The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : { - }, "The selected cookbook will open on app launch by default." : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Network/APIController.swift b/Nextcloud Cookbook iOS Client/Network/APIController.swift index b1b2c72..e8598ad 100644 --- a/Nextcloud Cookbook iOS Client/Network/APIController.swift +++ b/Nextcloud Cookbook iOS Client/Network/APIController.swift @@ -6,23 +6,21 @@ // import Foundation -import SwiftUI class APIController { - @AppStorage("serverAddress") var serverAddress = "" - @AppStorage("username") var username = "" - @AppStorage("token") var token = "" + var userSettings: UserSettings - var apiPath: String = "" - var authString: String = "" + var apiPath: String + var authString: String let apiVersion = "1" - init() { + init(userSettings: UserSettings) { print("Initializing APIController.") + self.userSettings = userSettings - self.apiPath = "https://\(serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" + self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" - let loginString = "\(username):\(token)" + let loginString = "\(userSettings.username):\(userSettings.token)" let loginData = loginString.data(using: String.Encoding.utf8)! self.authString = loginData.base64EncodedString() } diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index 329d1cd..25a31dd 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -9,27 +9,22 @@ import SwiftUI @main struct Nextcloud_Cookbook_iOS_ClientApp: App { - @StateObject var mainViewModel = MainViewModel() - @AppStorage("onboarding") var onboarding = true - @AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en" + @StateObject var userSettings = UserSettings() var body: some Scene { WindowGroup { ZStack { - if onboarding { - OnboardingView() + if userSettings.onboarding { + OnboardingView(userSettings: userSettings) } else { - MainView(viewModel: mainViewModel) - .onAppear { - mainViewModel.apiController = APIController() - } + MainView(userSettings: userSettings) } } .transition(.slide) .environment( \.locale, - .init(identifier: language == - SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language) + .init(identifier: userSettings.language == + SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : userSettings.language) ) } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index e772155..5343e3c 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -9,8 +9,8 @@ import SwiftUI struct MainView: View { - @ObservedObject var viewModel: MainViewModel - @StateObject var userSettings: UserSettings = UserSettings() + @ObservedObject var userSettings: UserSettings + @StateObject var viewModel = MainViewModel() @State private var selectedCategory: Category? = nil @State private var showEditView: Bool = false @@ -37,10 +37,6 @@ struct MainView: View { .padding(7) } - if viewModel.categories.isEmpty { - Text("Pull to refresh.") - } - // Categories ForEach(viewModel.categories) { category in if category.recipe_count != 0 { diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift deleted file mode 100644 index 34b5a2a..0000000 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// OnboardingView.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 15.09.23. -// - -import Foundation -import SwiftUI - -struct OnboardingView: View { - @State var selectedTab: Int = 0 - - var body: some View { - TabView(selection: $selectedTab) { - WelcomeTab().tag(0) - LoginTab().tag(1) - } - .tabViewStyle(.page) - .background( - selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea() - ) - .animation(.easeInOut, value: selectedTab) - } -} - -struct WelcomeTab: View { - var body: some View { - VStack(alignment: .center) { - Spacer() - Image("cookbook-icon") - .resizable() - .frame(width: 120, height: 120) - .clipShape(RoundedRectangle(cornerRadius: 10)) - Text("Thank you for downloading") - .font(.headline) - Text("Cookbook Client") - .font(.largeTitle) - .bold() - Spacer() - Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.") - .padding() - Spacer() - } - .padding() - .fontDesign(.rounded) - } -} - -protocol LoginStage { - func next() -> Self - func previous() -> Self -} - -enum LoginMethod { - case v2, token -} - -enum TokenLoginStage: LoginStage { - case serverAddress, userName, appToken, validate - - func next() -> TokenLoginStage { - switch self { - case .serverAddress: return .userName - case .userName: return .appToken - case .appToken: return .validate - case .validate: return .validate - } - } - - func previous() -> TokenLoginStage { - switch self { - case .serverAddress: return .serverAddress - case .userName: return .serverAddress - case .appToken: return .userName - case .validate: return .appToken - } - } -} - - - - - -struct LoginTab: View { - @State var loginMethod: LoginMethod = .v2 - - // Login error alert - @State var showAlert: Bool = false - @State var alertMessage: String = "Error: Could not connect to server." - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading) { - Spacer() - Picker("Login Method", selection: $loginMethod) { - Text("Nextcloud Login").tag(LoginMethod.v2) - Text("App Token Login").tag(LoginMethod.token) - } - .pickerStyle(.segmented) - .foregroundColor(.white) - .padding() - if loginMethod == .token { - TokenLoginView( - showAlert: $showAlert, - alertMessage: $alertMessage - ) - } - else if loginMethod == .v2 { - V2LoginView( - showAlert: $showAlert, - alertMessage: $alertMessage - ) - } - Spacer() - } - - .fontDesign(.rounded) - .padding() - .alert(alertMessage, isPresented: $showAlert) { - Button("Ok", role: .cancel) { } - } - } - } -} - - - - -struct LoginLabel: View { - let text: String - var body: some View { - Text(text) - .foregroundColor(.white) - .font(.headline) - .padding(.vertical, 5) - } -} - -struct LoginTextField: View { - var example: String - @Binding var text: String - @State var color: Color = .white - - var body: some View { - TextField(example, text: $text) - .textFieldStyle(.plain) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .foregroundColor(color) - .accentColor(color) - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(color, lineWidth: 2) - .foregroundColor(.clear) - ) - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift deleted file mode 100644 index 9270717..0000000 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// TokenLoginView.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 21.11.23. -// - -import Foundation -import SwiftUI - -struct TokenLoginView: View { - @Binding var showAlert: Bool - @Binding var alertMessage: String - @FocusState private var focusedField: Field? - - @AppStorage("serverAddress") var serverAddress = "" - @AppStorage("username") var userName = "" - @AppStorage("token") var token = "" - @AppStorage("onboarding") var onboarding = false - - // TextField handling - enum Field { - case server - case username - case token - } - - var body: some View { - VStack(alignment: .leading) { - LoginLabel(text: "Server address") - LoginTextField(example: "e.g.: example.com", text: $serverAddress) - .focused($focusedField, equals: .server) - .textContentType(.URL) - .submitLabel(.next) - .padding(.bottom) - - LoginLabel(text: "User name") - LoginTextField(example: "username", text: $userName) - .focused($focusedField, equals: .username) - .textContentType(.username) - .submitLabel(.next) - .padding(.bottom) - - - LoginLabel(text: "App Token") - LoginTextField(example: "can be generated in security settings of your nextcloud", text: $token) - .focused($focusedField, equals: .token) - .textContentType(.password) - .submitLabel(.join) - HStack{ - Spacer() - Button { - Task { - if await loginCheck(nextcloudLogin: false) { - onboarding = false - } - } - } label: { - Text("Submit") - .foregroundColor(.white) - .font(.headline) - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white, lineWidth: 2) - .foregroundColor(.clear) - ) - } - .padding() - Spacer() - } - } - .onSubmit { - switch focusedField { - case .server: - focusedField = .username - case .username: - focusedField = .token - default: - print("Attempting to log in ...") - } - } - } - - func loginCheck(nextcloudLogin: Bool) async -> Bool { - if serverAddress == "" { - alertMessage = "Please enter a server address!" - showAlert = true - return false - } else if !nextcloudLogin && (userName == "" || token == "") { - alertMessage = "Please enter a user name and app token!" - showAlert = true - return false - } - let headerFields = [ - HeaderField.ocsRequest(value: true), - ] - let request = RequestWrapper.customRequest( - method: .GET, - path: .CATEGORIES, - headerFields: headerFields, - authenticate: true - ) - - var (data, error): (Data?, Error?) = (nil, nil) - do { - let loginString = "\(userName):\(token)" - let loginData = loginString.data(using: String.Encoding.utf8)! - let authString = loginData.base64EncodedString() - (data, error) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: "https://\(serverAddress)/index.php/apps/cookbook/api/v1/", - authString: authString - ) - } catch { - print("Error: ", error) - } - guard let data = data else { - alertMessage = "Login failed. Please check your inputs." - showAlert = true - return false - } - if let testRequest: [Category] = JSONDecoder.safeDecode(data) { - print("validationResponse: \(testRequest)") - return true - } - alertMessage = "Login failed. Please check your inputs and internet connection." - showAlert = true - return false - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift deleted file mode 100644 index 9546103..0000000 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// V2LoginView.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 21.11.23. -// - -import Foundation -import SwiftUI - -enum V2LoginStage: LoginStage { - case serverAddress, login, validate - - func next() -> V2LoginStage { - switch self { - case .serverAddress: return .login - case .login: return .validate - case .validate: return .validate - } - } - - func previous() -> V2LoginStage { - switch self { - case .serverAddress: return .serverAddress - case .login: return .serverAddress - case .validate: return .login - } - } -} - -struct CollapsibleView: View { - @State var titleColor: Color = .white - @State var content: () -> T - @State var title: () -> Text - - @State var isCollapsed: Bool = true - @State var rotationAngle: Double = -90 - - var body: some View { - VStack(alignment: .leading) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isCollapsed.toggle() - if isCollapsed { - rotationAngle += 90 - } else { - rotationAngle -= 90 - } - } - rotationAngle = isCollapsed ? -90 : 0 - } label: { - HStack { - Image(systemName: "chevron.down") - .bold() - .rotationEffect(Angle(degrees: rotationAngle)) - title() - }.foregroundStyle(titleColor) - } - - if !isCollapsed { - content() - .padding(.top, 1) - } - } - } -} - -struct V2LoginView: View { - @Binding var showAlert: Bool - @Binding var alertMessage: String - - @State var loginStage: V2LoginStage = .serverAddress - @State var loginRequest: LoginV2Request? = nil - @FocusState private var focusedField: Field? - - @AppStorage("serverAddress") var serverAddress = "" - @AppStorage("username") var userName = "" - @AppStorage("token") var token = "" - @AppStorage("onboarding") var onboarding = true - - // TextField handling - enum Field { - case server - case username - case token - } - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - LoginLabel(text: "Server address") - .padding() - LoginTextField(example: "e.g.: example.com", text: $serverAddress, color: loginStage == .serverAddress ? .white : .secondary) - .focused($focusedField, equals: .server) - .textContentType(.URL) - .submitLabel(.done) - .padding([.bottom, .horizontal]) - .onSubmit { - withAnimation(.easeInOut) { - loginStage = loginStage.next() - } - } - - CollapsibleView { - VStack(alignment: .leading) { - Text("Make sure to enter the server address in the form 'example.com'. Currently, only servers using the 'https' protocol are supported.") - if let loginRequest = loginRequest { - Text("If the login button does not open your browser, copy the following link and paste it in your browser manually:") - Text(loginRequest.login) - } - } - } title: { - Text("Show help") - .foregroundColor(.white) - .font(.headline) - }.padding() - - if loginStage == .login || loginStage == .validate { - Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.") - .font(.subheadline) - .foregroundStyle(.white) - .padding() - } - HStack { - if loginStage == .login || loginStage == .validate { - Button { - if serverAddress == "" { - alertMessage = "Please enter a valid server address." - showAlert = true - return - } - - Task { - await sendLoginV2Request() - if let loginRequest = loginRequest { - await UIApplication.shared.open(URL(string: loginRequest.login)!) - } else { - alertMessage = "Unable to reach server. Please check your server address and internet connection." - showAlert = true - } - } - loginStage = loginStage.next() - } label: { - Text("Login") - .foregroundColor(.white) - .font(.headline) - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white, lineWidth: 2) - .foregroundColor(.clear) - ) - }.padding() - } - if loginStage == .validate { - Spacer() - - Button { - // fetch login v2 response - Task { - guard let res = await fetchLoginV2Response() else { - alertMessage = "Login failed. Please login via the browser and try again." - showAlert = true - return - } - print("Login successfull for user \(res.loginName)!") - self.userName = res.loginName - self.token = res.appPassword - self.onboarding = false - } - } label: { - Text("Validate") - .foregroundColor(.white) - .font(.headline) - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white, lineWidth: 2) - .foregroundColor(.clear) - ) - } - .disabled(loginRequest == nil ? true : false) - .padding() - } - } - } - } - } - - func sendLoginV2Request() async { - let hostPath = "https://\(serverAddress)" - let headerFields: [HeaderField] = [ - //HeaderField.ocsRequest(value: true), - //HeaderField.accept(value: .JSON) - ] - let request = RequestWrapper.customRequest( - method: .POST, - path: .LOGINV2REQ, - headerFields: headerFields - ) - do { - let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: hostPath, - authString: nil - ) - - guard let data = data else { return } - print("Data: \(data)") - let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data) - self.loginRequest = loginReq - } catch { - print("Could not establish communication with the server.") - } - - } - - func fetchLoginV2Response() async -> LoginV2Response? { - guard let loginRequest = loginRequest else { return nil } - let headerFields = [ - HeaderField.ocsRequest(value: true), - HeaderField.accept(value: .JSON), - HeaderField.contentType(value: .FORM) - ] - let request = RequestWrapper.customRequest( - method: .POST, - path: .NONE, - headerFields: headerFields, - body: "token=\(loginRequest.poll.token)".data(using: .utf8), - authenticate: false - ) - - var (data, error): (Data?, Error?) = (nil, nil) - do { - (data, error) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: loginRequest.poll.endpoint, - authString: nil - ) - } catch { - print("Error: ", error) - } - guard let data = data else { return nil } - if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) { - return loginRes - } - print("Could not decode.") - return nil - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift new file mode 100644 index 0000000..3ebacc6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift @@ -0,0 +1,355 @@ +// +// OnboardingView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 15.09.23. +// + +import Foundation +import SwiftUI + +struct OnboardingView: View { + @ObservedObject var userSettings: UserSettings + @State var selectedTab: Int = 0 + + var body: some View { + TabView(selection: $selectedTab) { + WelcomeTab().tag(0) + LoginTab(userSettings: userSettings).tag(1) + } + .tabViewStyle(.page) + .background( + selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea() + ) + .animation(.easeInOut, value: selectedTab) + } +} + +struct WelcomeTab: View { + var body: some View { + VStack(alignment: .center) { + Spacer() + Image("cookbook-icon") + .resizable() + .frame(width: 120, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Text("Thank you for downloading") + .font(.headline) + Text("Cookbook Client") + .font(.largeTitle) + .bold() + Spacer() + Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.") + .padding() + Spacer() + } + .padding() + .fontDesign(.rounded) + } +} + +struct LoginTab: View { + @ObservedObject var userSettings: UserSettings + + // Login flow + enum LoginMethod { + case v2, token + } + @State var selectedLoginMethod: LoginMethod = .v2 + @State var loginRequest: LoginV2Request? = nil + + // Login error alert + @State var showAlert: Bool = false + @State var alertMessage: String = "Error: Could not connect to server." + + // TextField handling + enum Field { + case server + case username + case token + } + @FocusState private var focusedField: Field? + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading) { + Spacer() + Picker("Login Method", selection: $selectedLoginMethod) { + Text("Nextcloud Login").tag(LoginMethod.v2) + Text("App Token Login").tag(LoginMethod.token) + } + .pickerStyle(.segmented) + .foregroundColor(.white) + if selectedLoginMethod == .token { + LoginLabel(text: "Server address") + LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress) + .focused($focusedField, equals: .server) + .textContentType(.URL) + .submitLabel(.next) + .padding(.bottom) + + LoginLabel(text: "User name") + LoginTextField(example: "username", text: $userSettings.username) + .focused($focusedField, equals: .username) + .textContentType(.username) + .submitLabel(.next) + .padding(.bottom) + + + LoginLabel(text: "App Token") + LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token) + .focused($focusedField, equals: .token) + .textContentType(.password) + .submitLabel(.join) + HStack{ + Spacer() + Button { + Task { + if await loginCheck(nextcloudLogin: false) { + userSettings.onboarding = false + } + } + } label: { + Text("Submit") + .foregroundColor(.white) + .font(.headline) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white, lineWidth: 2) + .foregroundColor(.clear) + ) + } + .padding() + Spacer() + } + } + else if selectedLoginMethod == .v2 { + LoginLabel(text: "Server address") + LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress) + .focused($focusedField, equals: .server) + .textContentType(.URL) + .submitLabel(.done) + .padding(.bottom) + .onSubmit { + if userSettings.serverAddress == "" { return } + Task { + await sendLoginV2Request() + if let loginRequest = loginRequest { + await UIApplication.shared.open(URL(string: loginRequest.login)!) + } else { + alertMessage = "Unable to reach server. Please check your server address and internet connection." + showAlert = true + } + } + } + Text("Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'.") + .font(.subheadline) + .padding(.bottom) + .foregroundStyle(.white) + Button { + Task { + await sendLoginV2Request() + if let loginRequest = loginRequest { + await UIApplication.shared.open(URL(string: loginRequest.login)!) + } else { + alertMessage = "Unable to reach server. Please check your server address and internet connection." + showAlert = true + } + } + } label: { + Text("Open in browser") + .foregroundColor(.white) + .font(.headline) + } + + HStack{ + Spacer() + + Button { + // fetch login v2 response + Task { + guard let res = await fetchLoginV2Response() else { + alertMessage = "Login failed. Please login via the browser and try again." + showAlert = true + return + } + print("Login successfull for user \(res.loginName)!") + userSettings.username = res.loginName + userSettings.token = res.appPassword + userSettings.onboarding = false + } + } label: { + Text("Validate") + .foregroundColor(.white) + .font(.headline) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white, lineWidth: 2) + .foregroundColor(.clear) + ) + } + .disabled(loginRequest == nil ? true : false) + .padding() + Spacer() + } + } + Spacer() + } + .onSubmit { + switch focusedField { + case .server: + focusedField = .username + case .username: + focusedField = .token + default: + print("Attempting to log in ...") + } + } + .fontDesign(.rounded) + .padding() + .alert(alertMessage, isPresented: $showAlert) { + Button("Ok", role: .cancel) { } + } + } + } + + func sendLoginV2Request() async { + let hostPath = "https://\(userSettings.serverAddress)" + let headerFields: [HeaderField] = [ + //HeaderField.ocsRequest(value: true), + //HeaderField.accept(value: .JSON) + ] + let request = RequestWrapper.customRequest( + method: .POST, + path: .LOGINV2REQ, + headerFields: headerFields + ) + do { + let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: hostPath, + authString: nil + ) + + guard let data = data else { return } + print("Data: \(data)") + let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data) + self.loginRequest = loginReq + } catch { + print("Could not establish communication with the server.") + } + + } + + func fetchLoginV2Response() async -> LoginV2Response? { + guard let loginRequest = loginRequest else { return nil } + let headerFields = [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: .JSON), + HeaderField.contentType(value: .FORM) + ] + let request = RequestWrapper.customRequest( + method: .POST, + path: .NONE, + headerFields: headerFields, + body: "token=\(loginRequest.poll.token)".data(using: .utf8), + authenticate: false + ) + + var (data, error): (Data?, Error?) = (nil, nil) + do { + (data, error) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: loginRequest.poll.endpoint, + authString: nil + ) + } catch { + print("Error: ", error) + } + guard let data = data else { return nil } + if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) { + return loginRes + } + print("Could not decode.") + return nil + } + + func loginCheck(nextcloudLogin: Bool) async -> Bool { + if userSettings.serverAddress == "" { + alertMessage = "Please enter a server address!" + showAlert = true + return false + } else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") { + alertMessage = "Please enter a user name and app token!" + showAlert = true + return false + } + let headerFields = [ + HeaderField.ocsRequest(value: true), + ] + let request = RequestWrapper.customRequest( + method: .GET, + path: .CATEGORIES, + headerFields: headerFields, + authenticate: true + ) + + var (data, error): (Data?, Error?) = (nil, nil) + do { + let loginString = "\(userSettings.username):\(userSettings.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + let authString = loginData.base64EncodedString() + (data, error) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/", + authString: authString + ) + } catch { + print("Error: ", error) + } + guard let data = data else { + alertMessage = "Login failed. Please check your inputs." + showAlert = true + return false + } + if let testRequest: [Category] = JSONDecoder.safeDecode(data) { + print("validationResponse: \(testRequest)") + return true + } + alertMessage = "Login failed. Please check your inputs and internet connection." + showAlert = true + return false + } +} + +struct LoginLabel: View { + let text: String + var body: some View { + Text(text) + .foregroundColor(.white) + .font(.headline) + .padding(.vertical, 5) + } +} + +struct LoginTextField: View { + var example: String + @Binding var text: String + + var body: some View { + TextField(example, text: $text) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .foregroundColor(.white) + .accentColor(.white) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white, lineWidth: 2) + .foregroundColor(.clear) + ) + } +}