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/3] 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/3] 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/3] 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()