From a3fc891d0abdfa22bcae8c4fa5fa30159cdd5706 Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Thu, 14 Dec 2023 14:11:56 +0100 Subject: [PATCH] Better file caching and update management --- .../project.pbxproj | 10 +- .../Contents.json | 12 +- .../Data/DataModels.swift | 34 ++- .../Data/UserSettings.swift | 30 +- .../Extensions/DateExtension.swift | 44 +++ .../Localizable.xcstrings | 38 ++- .../Network/APIController.swift | 80 ----- .../Network/CookbookApi/ApiRequest.swift | 7 +- .../Network/CookbookApi/CookbookApi.swift | 2 +- .../Network/CookbookApi/CookbookApiV1.swift | 6 +- .../RecipeImport/RecipeScraper.swift | 2 +- .../TestScraper.playground/Contents.swift | 118 -------- .../contents.xcplayground | 4 - .../ViewModels/MainViewModel.swift | 278 +++++++++++++----- .../ViewModels/RecipeEditViewModel.swift | 76 ++--- .../Views/Alerts.swift | 9 +- .../Views/CategoryDetailView.swift | 61 +--- .../Views/KeywordPickerView.swift | 24 +- .../Views/MainView.swift | 82 ++++-- .../Views/RecipeCardView.swift | 17 +- .../Views/RecipeDetailView.swift | 50 +++- .../Views/RecipeEditView.swift | 69 ++++- .../Views/SettingsView.swift | 22 +- 23 files changed, 592 insertions(+), 483 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift delete mode 100644 Nextcloud Cookbook iOS Client/Network/APIController.swift delete mode 100644 Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/Contents.swift delete mode 100644 Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/contents.xcplayground diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index bd64411..153e697 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -26,13 +26,13 @@ A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; - A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226C2ABAF90D00D7C4ED /* APIController.swift */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; }; A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; }; A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; }; A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; + A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.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 */; }; @@ -89,13 +89,12 @@ A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = ""; }; - A703226C2ABAF90D00D7C4ED /* APIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIController.swift; sourceTree = ""; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = ""; }; - A74D33BF2AF82CB500D06555 /* TestScraper.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = TestScraper.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = ""; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = ""; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; + A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.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 = ""; }; @@ -207,7 +206,6 @@ children = ( A79AA8EE2B063B33007D25F2 /* NextcloudApi */, A79AA8E72B062DB6007D25F2 /* CookbookApi */, - A703226C2ABAF90D00D7C4ED /* APIController.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171B02AB211DF00064C43 /* CustomError.swift */, @@ -258,6 +256,7 @@ A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */, + A787B0772B2B1E6400C2DF1B /* DateExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -272,7 +271,6 @@ A781E75F2AF8228100452F6F /* RecipeImport */ = { isa = PBXGroup; children = ( - A74D33BF2AF82CB500D06555 /* TestScraper.playground */, A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */, ); path = RecipeImport; @@ -455,6 +453,7 @@ A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, + A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, @@ -471,7 +470,6 @@ 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 */, diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/backgroundHighlight.colorset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/backgroundHighlight.colorset/Contents.json index 5512b50..ec813ed 100644 --- a/Nextcloud Cookbook iOS Client/Assets.xcassets/backgroundHighlight.colorset/Contents.json +++ b/Nextcloud Cookbook iOS Client/Assets.xcassets/backgroundHighlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.871", - "green" : "0.871", - "red" : "0.871" + "blue" : "0.948", + "green" : "0.948", + "red" : "0.948" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.871", - "green" : "0.871", - "red" : "0.871" + "blue" : "0.948", + "green" : "0.948", + "red" : "0.948" } }, "idiom" : "universal" diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 23f0685..c0f2b20 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -25,6 +25,13 @@ struct Recipe: Codable { let imageUrl: String let imagePlaceholderUrl: String let recipe_id: Int + + // Properties excluded from Codable + var storedLocally: Bool? = nil + + private enum CodingKeys: String, CodingKey { + case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id + } } extension Recipe: Identifiable, Hashable { @@ -91,16 +98,6 @@ struct RecipeDetail: Codable { recipeInstructions = [] nutrition = [:] } - - func getKeywordsArray() -> [String] { - return keywords.components(separatedBy: ",") - } - - mutating func setKeywordsFromArray(_ keywordsArray: [String]) { - if !self.keywords.isEmpty { - self.keywords = keywordsArray.joined(separator: ",") - } - } } extension RecipeDetail { @@ -124,7 +121,18 @@ extension RecipeDetail { recipeInstructions: [], nutrition: [:] ) - } + } + + func getKeywordsArray() -> [String] { + if keywords == "" { return [] } + return keywords.components(separatedBy: ",") + } + + mutating func setKeywordsFromArray(_ keywordsArray: [String]) { + if !keywordsArray.isEmpty { + self.keywords = keywordsArray.joined(separator: ",") + } + } func getNutritionList() -> [String]? { var stringList: [String] = [] @@ -146,8 +154,8 @@ extension RecipeDetail { struct RecipeImage { - enum RecipeImageSize { - case THUMB, FULL + enum RecipeImageSize: String { + case THUMB="thumb", FULL="full" } var imageExists: Bool = true var thumb: UIImage? diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index 62b950b..d2fa8df 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -10,6 +10,9 @@ import Foundation import Combine class UserSettings: ObservableObject { + + static let shared = UserSettings() + @Published var username: String { didSet { UserDefaults.standard.set(username, forKey: "username") @@ -52,9 +55,27 @@ class UserSettings: ObservableObject { } } - @Published var downloadRecipes: Bool { + @Published var storeRecipes: Bool { didSet { - UserDefaults.standard.set(downloadRecipes, forKey: "downloadRecipes") + UserDefaults.standard.set(storeRecipes, forKey: "storeRecipes") + } + } + + @Published var storeImages: Bool { + didSet { + UserDefaults.standard.set(storeImages, forKey: "storeImages") + } + } + + @Published var storeThumb: Bool { + didSet { + UserDefaults.standard.set(storeThumb, forKey: "storeThumb") + } + } + + @Published var lastUpdate: Date { + didSet { + UserDefaults.standard.set(lastUpdate, forKey: "lastUpdate") } } @@ -66,7 +87,10 @@ class UserSettings: ObservableObject { 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 + self.storeRecipes = UserDefaults.standard.object(forKey: "storeRecipes") as? Bool ?? true + self.storeImages = UserDefaults.standard.object(forKey: "storeImages") as? Bool ?? true + self.storeThumb = UserDefaults.standard.object(forKey: "storeThumb") as? Bool ?? true + self.lastUpdate = UserDefaults.standard.object(forKey: "lastUpdate") as? Date ?? Date.distantPast if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift new file mode 100644 index 0000000..10c3327 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Extensions/DateExtension.swift @@ -0,0 +1,44 @@ +// +// DateExtension.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 14.12.23. +// + +import Foundation + +extension Date { + static func convertUTCStringToLocalString(utcDateString: String, withFormat format: String = "yyyy-MM-dd HH:mm:ss") -> String? { + // DateFormatter for parsing the UTC date string + let inputFormatter = DateFormatter() + inputFormatter.dateFormat = format + inputFormatter.timeZone = TimeZone(secondsFromGMT: 0) // UTC + + // DateFormatter for converting to local time string + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = format // You can modify this format for different output styles + outputFormatter.timeZone = TimeZone.current // Device's local time zone + + // Convert the string to Date and then to local time string + if let date = inputFormatter.date(from: utcDateString) { + return outputFormatter.string(from: date) + } else { + return nil // Return nil if the input string is not in the correct format + } + } + + static func convertISOStringToLocalString(isoDateString: String, withFormat format: String = "yyyy-MM-dd HH:mm:ss") -> String? { + let inputFormatter = ISO8601DateFormatter() + // DateFormatter for converting to local time string + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = format // You can modify this format for different output styles + outputFormatter.timeZone = TimeZone.current // Device's local time zone + + // Convert the string to Date and then to local time string + if let date = inputFormatter.date(from: isoDateString) { + return outputFormatter.string(from: date) + } else { + return nil // Return nil if the input string is not in the correct format + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 80da4ab..003d85e 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -66,6 +66,9 @@ } } } + }, + "(%lld)" : { + }, "%@" : { "localizations" : { @@ -292,9 +295,6 @@ } } } - }, - "Action completed." : { - }, "Action delayed" : { @@ -496,6 +496,9 @@ } } } + }, + "Configure what is stored on your device." : { + }, "Connected to server." : { "localizations" : { @@ -631,6 +634,9 @@ }, "Could not establish a connection to the server. The action will be retried upon reconnection." : { + }, + "Created: %@" : { + }, "Delete" : { "localizations" : { @@ -765,6 +771,7 @@ } }, "Download all recipes" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -787,6 +794,7 @@ } }, "Download recipes" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -807,6 +815,9 @@ } } } + }, + "Downloads" : { + }, "Duplicate recipe." : { "localizations" : { @@ -1216,6 +1227,12 @@ } } } + }, + "Last modified: %@" : { + + }, + "Last updated: %@" : { + }, "Log out" : { "localizations" : { @@ -1486,6 +1503,9 @@ } } } + }, + "Offline recipes" : { + }, "Ok" : { "localizations" : { @@ -1707,6 +1727,9 @@ } } } + }, + "Refresh all" : { + }, "Same as Device" : { "localizations" : { @@ -1864,6 +1887,12 @@ }, "Show help" : { + }, + "Store recipe images locally" : { + + }, + "Store recipe thumbnails locally" : { + }, "Submit" : { "localizations" : { @@ -1886,9 +1915,6 @@ } } } - }, - "Success" : { - }, "Support" : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Network/APIController.swift b/Nextcloud Cookbook iOS Client/Network/APIController.swift deleted file mode 100644 index e8598ad..0000000 --- a/Nextcloud Cookbook iOS Client/Network/APIController.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// APIController.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 20.09.23. -// - -import Foundation - -class APIController { - var userSettings: UserSettings - - var apiPath: String - var authString: String - let apiVersion = "1" - - init(userSettings: UserSettings) { - print("Initializing APIController.") - self.userSettings = userSettings - - self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" - - let loginString = "\(userSettings.username):\(userSettings.token)" - let loginData = loginString.data(using: String.Encoding.utf8)! - self.authString = loginData.base64EncodedString() - } -} - - - -extension APIController { - func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? { - do { - let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb)) - let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: apiPath, - authString: authString - ) - guard let data = data else { - print("Error receiving or decoding data.") - return nil - } - return data - } catch { - print("Could not load image from server.") - } - return nil - } - - func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { - do { - let (data, error) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: apiPath, - authString: authString - ) - if let data = data { - return (JSONDecoder.safeDecode(data), error) - } - return (nil, error) - } catch { - print("An unknown network error occured.") - } - return (nil, NetworkError.unknownError) - } - - func sendRequest(_ request: RequestWrapper) async -> Error? { - do { - return try await NetworkHandler.sendHTTPRequest( - request, - hostPath: apiPath, - authString: authString - ).1 - } catch { - print("An unknown network error occured.") - } - return NetworkError.unknownError - } -} diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift index ea6f272..67498f5 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift @@ -77,10 +77,14 @@ struct ApiRequest { do { (data, response) = try await URLSession.shared.data(for: request) Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") + if let error = decodeURLResponse(response: response as? HTTPURLResponse) { + print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") + return (nil, error) + } if let data = data { print(data, String(data: data, encoding: .utf8)) } - return (data, nil) + return (data!, nil) } catch { let error = decodeURLResponse(response: response as? HTTPURLResponse) Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") @@ -92,6 +96,7 @@ struct ApiRequest { guard let response = response else { return NetworkError.unknownError } + print("Status code: ", response.statusCode) switch response.statusCode { case 200...299: return (nil) case 300...399: return (NetworkError.redirectionError) diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index 6bd6f24..6f70e20 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -132,7 +132,7 @@ protocol CookbookApi { static func getTags( from serverAdress: String, auth: String - ) async -> ([String]?, NetworkError?) + ) async -> ([RecipeKeyword]?, NetworkError?) /// Get all recipes tagged with the specified keyword. /// - Parameters: diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index e47e421..e5fad70 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -53,7 +53,7 @@ class CookbookApiV1: CookbookApi { 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), HeaderField.contentType(value: .JSON)], body: recipeData ) @@ -95,7 +95,7 @@ class CookbookApiV1: CookbookApi { 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), HeaderField.contentType(value: .JSON)], body: recipeData ) @@ -170,7 +170,7 @@ class CookbookApiV1: CookbookApi { return nil } - static func getTags(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) { + static func getTags(from serverAdress: String, auth: String) async -> ([RecipeKeyword]?, NetworkError?) { let request = ApiRequest( serverAdress: serverAdress, path: "/api/v1/keywords", diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift b/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift index edc5628..ac9f79f 100644 --- a/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift +++ b/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift @@ -11,7 +11,7 @@ import SwiftUI class RecipeScraper { - func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportError?) { + func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportAlert?) { var contents: String? = nil if let url = URL(string: url) { do { diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/Contents.swift b/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/Contents.swift deleted file mode 100644 index 2d92032..0000000 --- a/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/Contents.swift +++ /dev/null @@ -1,118 +0,0 @@ -import SwiftSoup -import Foundation - -class RecipeScraper { - func scrape(url: String) throws -> RecipeDetail? { - var contents: String? = nil - if let url = URL(string: url) { - do { - contents = try String(contentsOf: url) - } catch { - print("ERROR: Could not load url content.") - } - - } else { - print("ERROR: Bad url.") - } - - guard let html = contents else { - print("ERROR: no contents") - exit(1) - } - let doc = try SwiftSoup.parse(html) - - let elements: Elements = try doc.select("script") - for elem in elements.array() { - for attr in elem.getAttributes()!.asList() { - if attr.getValue() == "application/ld+json" { - guard let dict = toDict(elem) else { continue } - return getRecipe(fromDict: dict) - } - } - } - return nil - } - - - private func toDict(_ elem: Element) -> [String: Any]? { - var recipeDict: [String: Any]? = nil - do { - let jsonString = try elem.html() - //print(json) - let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: .fragmentsAllowed) - if let recipe = json as? [String : Any] { - recipeDict = recipe - } else if let recipe = (json as! [Any])[0] as? [String : Any] { - recipeDict = recipe - } - } catch { - print("Unable to decode json") - return nil - } - - guard let recipeDict = recipeDict else { - print("Json is not a dict") - return nil - } - - if recipeDict["@type"] as? String ?? "" == "Recipe" { - return recipeDict - } else if (recipeDict["@type"] as? [String] ?? []).contains("Recipe") { - return recipeDict - } else { - print("Json dict is not a recipe ...") - return nil - } - } - - private func getRecipe(fromDict recipe: Dictionary) -> RecipeDetail? { - - var recipeDetail = RecipeDetail() - recipeDetail.name = recipe["name"] as? String ?? "New Recipe" - recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? "" - recipeDetail.keywords = recipe["keywords"] as? String ?? "" - recipeDetail.description = recipe["description"] as? String ?? "" - recipeDetail.dateCreated = recipe["dateCreated"] as? String ?? "" - recipeDetail.dateModified = recipe["dateModified"] as? String ?? "" - recipeDetail.imageUrl = recipe["imageUrl"] as? String ?? "" - recipeDetail.url = recipe["url"] as? String ?? "" - recipeDetail.cookTime = recipe["cookTime"] as? String ?? "" - recipeDetail.prepTime = recipe["prepTime"] as? String ?? "" - recipeDetail.totalTime = recipe["totalTime"] as? String ?? "" - recipeDetail.recipeInstructions = stringArrayForKey("recipeInstructions", dict: recipe) - recipeDetail.recipeYield = recipe["recipeYield"] as? Int ?? 0 - recipeDetail.recipeIngredient = recipe["recipeIngredient"] as? [String] ?? [] - recipeDetail.tool = recipe["tool"] as? [String] ?? [] - recipeDetail.nutrition = recipe["nutrition"] as? [String:String] ?? [:] - - return recipeDetail - } - - private func stringArrayForKey(_ key: String, dict: Dictionary) -> [String] { - if let value = dict[key] as? [String] { - return value - } else if let orderedList = dict[key] as? [Any] { - var entries: [String] = [] - for dict in orderedList { - guard let dict = dict as? [String: Any] else { continue } - guard let text = dict["text"] as? String else { continue } - entries.append(text) - } - return entries - } - return [] - } -} - - -//let url = "https://www.chefkoch.de/rezepte/1385981243676608/Knusprige-Entenbrust.html" -let url = "https://www.allrecipes.com/recipe/234620/mascarpone-mashed-potatoes/" - -let scraper = RecipeScraper() - -do { - let recipe = try scraper.scrape(url: url) - print(recipe) -} catch { - print("No recipe on this website found.") -} diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/contents.xcplayground b/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/contents.xcplayground deleted file mode 100644 index cf026f2..0000000 --- a/Nextcloud Cookbook iOS Client/RecipeImport/TestScraper.playground/contents.xcplayground +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index ad6d4da..380ed81 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -11,15 +11,13 @@ import UIKit @MainActor class MainViewModel: ObservableObject { - @AppStorage("authString") var authString = "" - @AppStorage("username") var userName = "" - @AppStorage("token") var appToken = "" - @AppStorage("serverAddress") var serverAdress = "" - + @ObservedObject var userSettings = UserSettings.shared @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:] + var recipeImages: [Int: [String: UIImage]] = [:] + var imagesNeedUpdate: [Int: [String: Bool]] = [:] private var requestQueue: [RequestWrapper] = [] private let api: CookbookApi.Type @@ -30,10 +28,10 @@ import UIKit self.api = api self.dataStore = DataStore() - if authString == "" { - let loginString = "\(userName):\(appToken)" + if userSettings.authString == "" { + let loginString = "\(userSettings.username):\(userSettings.token)" let loginData = loginString.data(using: String.Encoding.utf8)! - authString = loginData.base64EncodedString() + userSettings.authString = loginData.base64EncodedString() } } @@ -51,21 +49,26 @@ import UIKit */ func getCategories() async { let (categories, _) = await api.getCategories( - from: serverAdress, - auth: authString + from: userSettings.serverAddress, + auth: userSettings.authString ) if let categories = categories { + print("Successfully loaded categories") self.categories = categories + print(categories) await saveLocal(categories, path: "categories.data") } else { // If there's no server connection, try loading categories from local storage + print("Loading categories from store ...") if let categories: [Category] = await loadLocal(path: "categories.data") { self.categories = categories + print("Success!") + } else { + print("Failure!") } } } - /** Fetches recipes for a specified category from either the server or local storage. @@ -88,14 +91,18 @@ import UIKit return false } - func getServer() async -> Bool { + func getServer(store: Bool = false) async -> Bool { let (recipes, _) = await api.getCategory( - from: serverAdress, - auth: authString, - named: name + from: userSettings.serverAddress, + auth: userSettings.authString, + named: categoryString ) if let recipes = recipes { self.recipes[name] = recipes + if store { + await saveLocal(recipes, path: "category_\(categoryString).data") + } + //userSettings.lastUpdate = Date() return true } return false @@ -105,9 +112,9 @@ import UIKit switch fetchMode { case .preferLocal: if await getLocal() { return } - if await getServer() { return } + if await getServer(store: true) { return } case .preferServer: - if await getServer() { return } + if await getServer(store: true) { return } if await getLocal() { return } case .onlyLocal: if await getLocal() { return } @@ -116,6 +123,26 @@ import UIKit } } + func updateAllRecipeDetails() async { + for category in self.categories { + await updateRecipeDetails(in: category.name) + } + userSettings.lastUpdate = Date() + } + + func updateRecipeDetails(in category: String) async { + guard userSettings.storeRecipes else { return } + guard let recipes = self.recipes[category] else { return } + for recipe in recipes { + if needsUpdate(lastModified: recipe.dateModified) { + print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)") + await updateRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages) + } else { + print("\(recipe.name) is up to date.") + } + } + } + /** Asynchronously retrieves all recipes either from the server or the locally cached data. @@ -129,8 +156,8 @@ import UIKit */ func getRecipes() async -> [Recipe] { let (recipes, error) = await api.getRecipes( - from: serverAdress, - auth: authString + from: userSettings.serverAddress, + auth: userSettings.authString ) if let recipes = recipes { return recipes @@ -162,18 +189,16 @@ import UIKit ```swift let recipeDetail = await mainViewModel.getRecipe(id: 123) */ - func getRecipe(id: Int, fetchMode: FetchMode) async -> RecipeDetail { + func getRecipe(id: Int, fetchMode: FetchMode) async -> RecipeDetail? { func getLocal() async -> RecipeDetail? { - if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { - return recipe - } + if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } return nil } func getServer() async -> RecipeDetail? { let (recipe, error) = await api.getRecipe( - from: serverAdress, - auth: authString, + from: userSettings.serverAddress, + auth: userSettings.authString, id: id ) if let recipe = recipe { @@ -196,7 +221,7 @@ import UIKit case .onlyServer: if let recipe = await getServer() { return recipe } } - return .error + return nil } @@ -213,26 +238,36 @@ import UIKit ```swift await mainViewModel.downloadAllRecipes() */ + /* func downloadAllRecipes() async { for category in categories { 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, fetchMode: .onlyServer) - await saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") - - 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, 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") + await downloadRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages) } } } + */ + func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async { + if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) { + await saveLocal(recipeDetail, path: "recipe\(id).data") + } + + if withThumb { + let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer) + guard let thumbnail = thumbnail else { return } + guard let thumbnailData = thumbnail.pngData() else { return } + await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb") + } + + if withImage { + let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer) + guard let image = image else { return } + guard let imageData = image.pngData() else { return } + await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full") + } + } /// Check if recipeDetail is stored locally, either in cache or on disk @@ -240,9 +275,7 @@ import UIKit /// - recipeId: The id of a recipe. /// - Returns: True if the recipeDetail is stored, otherwise false func recipeDetailExists(recipeId: Int) -> Bool { - if recipeDetails[recipeId] != nil { - return true - } else if (dataStore.recipeDetailExists(recipeId: recipeId)) { + if (dataStore.recipeDetailExists(recipeId: recipeId)) { return true } return false @@ -271,8 +304,8 @@ import UIKit func getServer() async -> UIImage? { let (image, _) = await api.getImage( - from: serverAdress, - auth: authString, + from: userSettings.serverAddress, + auth: userSettings.authString, id: id, size: size ) @@ -282,16 +315,51 @@ import UIKit switch fetchMode { case .preferLocal: - if let image = await getLocal() { return image } - if let image = await getServer() { return image } + if let image = imageFromCache(id: id, size: size) { + return image + } + if !imageUpdateNeeded(id: id, size: size) { return nil } + if let image = await getLocal() { + imageToCache(id: id, size: size, image: image) + return image + } + if let image = await getServer() { + await imageToStore(id: id, size: size, image: image) + imageToCache(id: id, size: size, image: image) + return image + } case .preferServer: - if let image = await getServer() { return image } - if let image = await getLocal() { return image } + if let image = await getServer() { + await imageToStore(id: id, size: size, image: image) + imageToCache(id: id, size: size, image: image) + return image + } + if let image = imageFromCache(id: id, size: size) { + return image + } + if let image = await getLocal() { + imageToCache(id: id, size: size, image: image) + return image + } case .onlyLocal: - if let image = await getLocal() { return image } + if let image = imageFromCache(id: id, size: size) { + return image + } + if !imageUpdateNeeded(id: id, size: size) { return nil } + if let image = await getLocal() { + imageToCache(id: id, size: size, image: image) + return image + } case .onlyServer: - if let image = await getServer() { return image } + if let image = imageFromCache(id: id, size: size) { + return image + } + if let image = await getServer() { + imageToCache(id: id, size: size, image: image) + return image + } } + imagesNeedUpdate[id] = [size.rawValue: false] return nil } @@ -306,15 +374,15 @@ import UIKit ```swift let keywords = await mainViewModel.getKeywords() */ - func getKeywords(fetchMode: FetchMode) async -> [String] { - func getLocal() async -> [String]? { + func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] { + func getLocal() async -> [RecipeKeyword]? { return await loadLocal(path: "keywords.data") } - func getServer() async -> [String]? { + func getServer() async -> [RecipeKeyword]? { let (tags, _) = await api.getTags( - from: serverAdress, - auth: authString + from: userSettings.serverAddress, + auth: userSettings.authString ) return tags } @@ -322,9 +390,15 @@ import UIKit switch fetchMode { case .preferLocal: if let keywords = await getLocal() { return keywords } - if let keywords = await getServer() { return keywords } + if let keywords = await getServer() { + await saveLocal(keywords, path: "keywords.data") + return keywords + } case .preferServer: - if let keywords = await getServer() { return keywords } + if let keywords = await getServer() { + await saveLocal(keywords, path: "keywords.data") + return keywords + } if let keywords = await getLocal() { return keywords } case .onlyLocal: if let keywords = await getLocal() { return keywords } @@ -340,6 +414,8 @@ import UIKit self.recipes = [:] self.recipeDetails = [:] self.requestQueue = [] + self.recipeImages = [:] + self.imagesNeedUpdate = [:] } } @@ -358,10 +434,10 @@ import UIKit ```swift let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts") */ - func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { let (error) = await api.deleteRecipe( - from: serverAdress, - auth: authString, + from: userSettings.serverAddress, + auth: userSettings.authString, id: id ) @@ -376,7 +452,7 @@ import UIKit }) recipeDetails.removeValue(forKey: id) } - return .REQUEST_SUCCESS + return nil } /** @@ -392,8 +468,8 @@ import UIKit */ func checkServerConnection() async -> Bool { let (categories, _) = await api.getCategories( - from: serverAdress, - auth: authString + from: userSettings.serverAddress, + auth: userSettings.authString ) if let categories = categories { self.categories = categories @@ -418,25 +494,25 @@ import UIKit ```swift let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true) */ - func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? { var error: NetworkError? = nil if createNew { error = await api.createRecipe( - from: serverAdress, - auth: authString, + from: userSettings.serverAddress, + auth: userSettings.authString, recipe: recipeDetail ) } else { error = await api.updateRecipe( - from: serverAdress, - auth: authString, + from: userSettings.serverAddress, + auth: userSettings.authString, recipe: recipeDetail ) } if let error = error { return .REQUEST_DROPPED } - return .REQUEST_SUCCESS + return nil } } @@ -472,6 +548,72 @@ extension MainViewModel { } return nil } + + private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async { + if let data = image.pngData() { + await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)") + } + } + + private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) { + if recipeImages[id] != nil { + recipeImages[id]![size.rawValue] = image + } else { + recipeImages[id] = [size.rawValue : image] + } + if imagesNeedUpdate[id] != nil { + imagesNeedUpdate[id]![size.rawValue] = false + } else { + imagesNeedUpdate[id] = [size.rawValue: false] + } + } + + private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? { + if recipeImages[id] != nil { + return recipeImages[id]![size.rawValue] + } + return nil + } + + private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool { + if imagesNeedUpdate[id] != nil { + if imagesNeedUpdate[id]![size.rawValue] != nil { + return imagesNeedUpdate[id]![size.rawValue]! + } + } + return true + } + + private func needsUpdate(lastModified: String) -> Bool { + print("=======================") + print("original date string: \(lastModified)") + // Create a DateFormatter + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + //dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Set the locale to posix + + // Convert the string to a Date object + if let date = dateFormatter.date(from: lastModified) { + if date < userSettings.lastUpdate { + print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: userSettings.lastUpdate))") + return false + } else { + print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: userSettings.lastUpdate))") + return true + } + } + print("String is not a date. Update needed.") + return true + } } +extension DateFormatter { + static func utcToString(date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + return dateFormatter.string(from: date) + } +} diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index aced57b..de80f7f 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -10,7 +10,6 @@ import SwiftUI @MainActor class RecipeEditViewModel: ObservableObject { @ObservedObject var mainViewModel: MainViewModel - @Published var isPresented: Binding @Published var recipe: RecipeDetail = RecipeDetail() @Published var prepDuration: DurationComponents = DurationComponents() @@ -19,29 +18,25 @@ import SwiftUI @Published var searchText: String = "" @Published var keywords: [String] = [] - @Published var keywordSuggestions: [String] = [] + @Published var keywordSuggestions: [RecipeKeyword] = [] @Published var showImportSection: Bool = false @Published var importURL: String = "" - @Published var presentAlert = false - var alertType: UserAlert = RecipeCreationError.GENERIC - var alertAction: @MainActor () async -> (RequestAlert) = { return .REQUEST_DROPPED } + var uploadNew: Bool = true var waitingForUpload: Bool = false - init(mainViewModel: MainViewModel, isPresented: Binding, uploadNew: Bool) { + init(mainViewModel: MainViewModel, uploadNew: Bool) { self.mainViewModel = mainViewModel - self.isPresented = isPresented self.uploadNew = uploadNew } - init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, isPresented: Binding, uploadNew: Bool) { + init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, uploadNew: Bool) { self.mainViewModel = mainViewModel self.recipe = recipeDetail - self.isPresented = isPresented self.uploadNew = uploadNew } @@ -53,13 +48,10 @@ import SwiftUI self.recipe.setKeywordsFromArray(keywords) } - func recipeValid() -> Bool { + func recipeValid() -> RecipeAlert? { // Check if the recipe has a name if recipe.name.replacingOccurrences(of: " ", with: "") == "" { - alertType = RecipeCreationError.NO_TITLE - alertAction = {return .REQUEST_DROPPED} - presentAlert = true - return false + return RecipeAlert.NO_TITLE } // Check if the recipe has a unique name for recipeList in mainViewModel.recipes.values { @@ -71,52 +63,41 @@ import SwiftUI .replacingOccurrences(of: " ", with: "") .lowercased() { - alertType = RecipeCreationError.DUPLICATE - alertAction = {return .REQUEST_DROPPED} - presentAlert = true - return false + return RecipeAlert.DUPLICATE } } } - return true + return nil } - func uploadNewRecipe() async -> RequestAlert { + func uploadNewRecipe() async -> UserAlert? { print("Uploading new recipe.") waitingForUpload = true createRecipe() - guard recipeValid() else { return .REQUEST_DROPPED } + if let recipeValidationError = recipeValid() { + return recipeValidationError + } return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true) } - func uploadEditedRecipe() async -> RequestAlert { + func uploadEditedRecipe() async -> UserAlert? { waitingForUpload = true print("Uploading changed recipe.") - guard let recipeId = Int(recipe.id) else { return .REQUEST_DROPPED } + guard let recipeId = Int(recipe.id) else { return RequestAlert.REQUEST_DROPPED } createRecipe() return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false) } - func deleteRecipe() async -> RequestAlert { + func deleteRecipe() async -> RequestAlert? { guard let id = Int(recipe.id) else { return .REQUEST_DROPPED } return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory) } - - - func dismissEditView() { - Task { - await mainViewModel.getCategories() - await mainViewModel.getCategory(named: recipe.recipeCategory, fetchMode: .preferServer) - } - isPresented.wrappedValue = false - } - func prepareView() { if let prepTime = recipe.prepTime { prepDuration.fromPTString(prepTime) @@ -130,22 +111,19 @@ import SwiftUI self.keywords = recipe.getKeywordsArray() } - func importRecipe() { - Task { - do { - let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) - if let scrapedRecipe = scrapedRecipe { - self.recipe = scrapedRecipe - prepareView() - } - if let error = error { - self.alertType = error - self.alertAction = {return .REQUEST_DROPPED} - self.presentAlert = true - } - } catch { - print("Error") + func importRecipe() async -> UserAlert? { + do { + let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) + if let scrapedRecipe = scrapedRecipe { + self.recipe = scrapedRecipe + prepareView() } + if let error = error { + return error + } + } catch { + print("Error") } + return nil } } diff --git a/Nextcloud Cookbook iOS Client/Views/Alerts.swift b/Nextcloud Cookbook iOS Client/Views/Alerts.swift index 41f7873..6608936 100644 --- a/Nextcloud Cookbook iOS Client/Views/Alerts.swift +++ b/Nextcloud Cookbook iOS Client/Views/Alerts.swift @@ -25,7 +25,7 @@ enum AlertButton: LocalizedStringKey, Identifiable { -enum RecipeCreationError: UserAlert { +enum RecipeAlert: UserAlert { case NO_TITLE, DUPLICATE, @@ -84,7 +84,7 @@ enum RecipeCreationError: UserAlert { } -enum RecipeImportError: UserAlert { +enum RecipeImportAlert: UserAlert { case BAD_URL, CHECK_CONNECTION, WEBSITE_NOT_SUPPORTED @@ -113,14 +113,12 @@ enum RecipeImportError: UserAlert { enum RequestAlert: UserAlert { case REQUEST_DELAYED, - REQUEST_DROPPED, - REQUEST_SUCCESS + REQUEST_DROPPED 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." } } @@ -128,7 +126,6 @@ enum RequestAlert: UserAlert { switch self { case .REQUEST_DELAYED: return "Action delayed" case .REQUEST_DROPPED: return "Error" - case .REQUEST_SUCCESS: return "Success" } } diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index 384b05d..3a049a9 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -22,6 +22,7 @@ struct CategoryDetailView: View { ForEach(recipesFiltered(), id: \.recipe_id) { recipe in NavigationLink(value: recipe) { RecipeCardView(viewModel: viewModel, recipe: recipe) + .shadow(radius: 2) } .buttonStyle(.plain) } @@ -30,41 +31,29 @@ struct CategoryDetailView: View { .navigationDestination(for: Recipe.self) { recipe in RecipeDetailView(viewModel: viewModel, recipe: recipe) } - .navigationTitle(categoryName == "*" ? "Other" : categoryName) + .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .toolbar { ToolbarItem(placement: .topBarTrailing) { - - Menu { - Button { - print("Add new recipe") - showEditView = true - } label: { - HStack { - Text("Add new recipe") - Image(systemName: "plus.circle.fill") - } - } - Button { - print("Downloading all recipes in category \(categoryName) ...") - downloadRecipes() - } label: { - HStack { - Text("Download recipes") - Image(systemName: "icloud.and.arrow.down") - } - } - + Button { + print("Add new recipe") + showEditView = true } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "plus.circle.fill") } } } .searchable(text: $searchText, prompt: "Search recipes") .task { - await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal) + await viewModel.getCategory( + named: categoryName, + fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer + ) } .refreshable { - await viewModel.getCategory(named: categoryName, fetchMode: .preferServer) + await viewModel.getCategory( + named: categoryName, + fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer + ) } } @@ -75,26 +64,4 @@ struct CategoryDetailView: View { recipe.name.lowercased().contains(searchText.lowercased()) } } - - - func downloadRecipes() { - if let recipes = viewModel.recipes[categoryName] { - Task { - for recipe in recipes { - 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, 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, 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/KeywordPickerView.swift b/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift index 2a06a36..2a7b6e7 100644 --- a/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift @@ -12,7 +12,7 @@ import SwiftUI struct KeywordPickerView: View { @State var title: String - @State var searchSuggestions: [String] + @State var searchSuggestions: [RecipeKeyword] @Binding var selection: [String] @State var searchText: String = "" @@ -35,17 +35,18 @@ struct KeywordPickerView: View { s == keyword ? true : false }) searchSuggestions.removeAll(where: { s in - s == keyword ? true : false + s.name == keyword ? true : false }) } else { selection.append(keyword) } } } - ForEach(suggestionsFiltered(), id: \.self) { suggestion in + ForEach(suggestionsFiltered(), id: \.name) { suggestion in KeywordItemView( - keyword: suggestion, - isSelected: selection.contains(suggestion) + keyword: suggestion.name, + count: suggestion.recipe_count, + isSelected: selection.contains(suggestion.name) ) { keyword in if selection.contains(keyword) { selection.removeAll(where: { s in @@ -84,14 +85,17 @@ struct KeywordPickerView: View { } } .navigationTitle(title) + .padding(5) } - func suggestionsFiltered() -> [String] { + func suggestionsFiltered() -> [RecipeKeyword] { guard searchText != "" else { return searchSuggestions } return searchSuggestions.filter { suggestion in - suggestion.lowercased().contains(searchText.lowercased()) - } + suggestion.name.lowercased().contains(searchText.lowercased()) + }.sorted(by: { a, b in + a.recipe_count > b.recipe_count + }) } } @@ -99,6 +103,7 @@ struct KeywordPickerView: View { struct KeywordItemView: View { var keyword: String + var count: Int? = nil var isSelected: Bool var tapped: (String) -> () @@ -110,6 +115,9 @@ struct KeywordItemView: View { Text(keyword) .lineLimit(2) Spacer() + if let count = count { + Text("(\(count))") + } } .padding() .background( diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 401a2cc..0648017 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -10,13 +10,14 @@ import SwiftUI struct MainView: View { @ObservedObject var viewModel: MainViewModel - @StateObject var userSettings: UserSettings = UserSettings() + @StateObject var userSettings: UserSettings = UserSettings.shared @State private var selectedCategory: Category? = nil @State private var showEditView: Bool = false @State private var showSearchView: Bool = false @State private var showSettingsView: Bool = false @State private var serverConnection: Bool = false + @State private var showLoadingIndicator: Bool = false var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] @@ -43,7 +44,7 @@ struct MainView: View { NavigationLink(value: category) { HStack(alignment: .center) { Image(systemName: "book.closed.fill") - Text(category.name == "*" ? "Other" : category.name) + Text(category.name == "*" ? String(localized: "Other") : category.name) .font(.system(size: 20, weight: .light, design: .serif)) .italic() }.padding(7) @@ -53,7 +54,7 @@ struct MainView: View { } .navigationTitle("Cookbooks") .navigationDestination(isPresented: $showSettingsView) { - SettingsView(userSettings: userSettings, viewModel: viewModel) + SettingsView(viewModel: viewModel) } .navigationDestination(isPresented: $showSearchView) { RecipeSearchView(viewModel: viewModel) @@ -63,7 +64,8 @@ struct MainView: View { viewModel: viewModel, showEditView: $showEditView, showSettingsView: $showSettingsView, - serverConnection: $serverConnection + serverConnection: $serverConnection, + showLoadingIndicator: $showLoadingIndicator ) } } detail: { @@ -80,17 +82,24 @@ struct MainView: View { } .tint(.nextcloudBlue) .sheet(isPresented: $showEditView) { - RecipeEditView(viewModel: - RecipeEditViewModel( - mainViewModel: viewModel, - isPresented: $showEditView, - uploadNew: true - ) + RecipeEditView( + viewModel: + RecipeEditViewModel( + mainViewModel: viewModel, + uploadNew: true + ), + isPresented: $showEditView ) } .task { + showLoadingIndicator = true self.serverConnection = await viewModel.checkServerConnection() - await viewModel.getCategories()//viewModel.loadCategoryList() + await viewModel.getCategories() + for category in viewModel.categories { + await viewModel.getCategory(named: category.name, fetchMode: .preferLocal) + } + await viewModel.updateAllRecipeDetails() + // Open detail view for default category if userSettings.defaultCategory != "" { if let cat = viewModel.categories.first(where: { c in @@ -102,10 +111,11 @@ struct MainView: View { self.selectedCategory = cat } } + showLoadingIndicator = false } .refreshable { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.getCategories()//loadCategoryList(needsUpdate: true) + await viewModel.getCategories() } } @@ -119,30 +129,33 @@ struct MainView: View { @Binding var showEditView: Bool @Binding var showSettingsView: Bool @Binding var serverConnection: Bool + @Binding var showLoadingIndicator: Bool @State private var presentPopover: Bool = false var body: some ToolbarContent { // Top left menu toolbar item ToolbarItem(placement: .topBarLeading) { Menu { - Button { - print("Downloading all recipes ...") - Task { - await viewModel.downloadAllRecipes() - } - } label: { - HStack { - Text("Download all recipes") - Image(systemName: "icloud.and.arrow.down") - } - } - Button { self.showSettingsView = true } label: { Text("Settings") Image(systemName: "gearshape") } + Button { + Task { + showLoadingIndicator = true + await viewModel.getCategories() + for category in viewModel.categories { + await viewModel.getCategory(named: category.name, fetchMode: .preferServer) + } + await viewModel.updateAllRecipeDetails() + showLoadingIndicator = false + } + } label: { + Text("Refresh all") + Image(systemName: "icloud.and.arrow.down") + } } label: { Image(systemName: "ellipsis.circle") } @@ -154,16 +167,24 @@ struct MainView: View { print("Check server connection") presentPopover = true } label: { - if serverConnection { + if showLoadingIndicator { + ProgressView() + } else if serverConnection { Image(systemName: "checkmark.icloud") } else { Image(systemName: "xmark.icloud") } }.popover(isPresented: $presentPopover) { - Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server.")) - .bold() - .padding() - .presentationCompactAdaptation(.popover) + VStack(alignment: .leading) { + Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server.")) + .bold() + + Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))") + .font(.caption) + .foregroundStyle(Color.secondary) + } + .padding() + .presentationCompactAdaptation(.popover) } } @@ -195,6 +216,7 @@ struct RecipeSearchView: View { ForEach(recipesFiltered(), id: \.recipe_id) { recipe in NavigationLink(value: recipe) { RecipeCardView(viewModel: viewModel, recipe: recipe) + .shadow(radius: 2) } .buttonStyle(.plain) } @@ -208,7 +230,7 @@ struct RecipeSearchView: View { .navigationTitle("Search recipe") } .task { - allRecipes = await viewModel.getRecipes()//.getAllRecipes() + allRecipes = await viewModel.getRecipes() } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index 9db1e67..c2ca84f 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -51,11 +51,22 @@ struct RecipeCardView: View { .clipShape(RoundedRectangle(cornerRadius: 17)) .padding(.horizontal) .task { - recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) - self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) + recipeThumb = await viewModel.getImage( + id: recipe.recipe_id, + size: .THUMB, + fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer + ) + if recipe.storedLocally == nil { + recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) + } + isDownloaded = recipe.storedLocally } .refreshable { - recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferServer) + recipeThumb = await viewModel.getImage( + id: recipe.recipe_id, + size: .THUMB, + fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer + ) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index cd70f58..992f871 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -75,6 +75,14 @@ struct RecipeDetailView: View { RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover) RecipeKeywordSection(recipeDetail: recipeDetail, presentKeywordPopover: $presentKeywordPopover) } + VStack(alignment: .leading) { + + Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")") + Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")") + } + .font(.caption) + .foregroundStyle(Color.secondary) + .padding() }.padding(.horizontal, 5) } @@ -95,24 +103,42 @@ struct RecipeDetailView: View { } .sheet(isPresented: $presentEditView) { if let recipeDetail = recipeDetail { - RecipeEditView(viewModel: - RecipeEditViewModel( - mainViewModel: viewModel, - recipeDetail: recipeDetail, - isPresented: $presentEditView, - uploadNew: false - ) + RecipeEditView( + viewModel: + RecipeEditViewModel( + mainViewModel: viewModel, + recipeDetail: recipeDetail, + uploadNew: false + ), + isPresented: $presentEditView ) } } .task { - 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) + recipeDetail = await viewModel.getRecipe( + id: recipe.recipe_id, + fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer + ) + recipeImage = await viewModel.getImage( + id: recipe.recipe_id, + size: .FULL, + fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer + ) + if recipe.storedLocally == nil { + recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) + } + self.isDownloaded = recipe.storedLocally } .refreshable { - recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer) - recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer) + recipeDetail = await viewModel.getRecipe( + id: recipe.recipe_id, + fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer + ) + recipeImage = await viewModel.getImage( + id: recipe.recipe_id, + size: .FULL, + fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer + ) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index 463d453..a90455e 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -12,14 +12,19 @@ import PhotosUI struct RecipeEditView: View { - @ObservedObject var viewModel: RecipeEditViewModel + @ObservedObject var viewModel: RecipeEditViewModel + @Binding var isPresented: Bool + + @State var presentAlert = false + @State var alertType: UserAlert = RecipeAlert.GENERIC + @State var alertAction: @MainActor () async -> () = { } var body: some View { NavigationStack { VStack { HStack { Button() { - viewModel.isPresented.wrappedValue = false + isPresented = false } label: { Text("Cancel") .bold() @@ -28,9 +33,17 @@ struct RecipeEditView: View { Menu { Button { print("Delete recipe.") - viewModel.alertType = RecipeCreationError.CONFIRM_DELETE - viewModel.alertAction = viewModel.deleteRecipe - viewModel.presentAlert = true + alertType = RecipeAlert.CONFIRM_DELETE + alertAction = { + if let res = await viewModel.deleteRecipe() { + alertType = res + alertAction = { } + presentAlert = true + } else { + self.dismissEditView() + } + } + presentAlert = true } label: { Image(systemName: "trash") .foregroundStyle(.red) @@ -47,9 +60,19 @@ struct RecipeEditView: View { Button() { Task { if viewModel.uploadNew { - await viewModel.uploadNewRecipe() + if let res = await viewModel.uploadNewRecipe() { + alertType = res + presentAlert = true + } else { + dismissEditView() + } } else { - await viewModel.uploadEditedRecipe() + if let res = await viewModel.uploadEditedRecipe() { + alertType = res + presentAlert = true + } else { + dismissEditView() + } } } } label: { @@ -58,7 +81,7 @@ struct RecipeEditView: View { } }.padding() HStack { - Text(viewModel.recipe.name == "" ? LocalizedStringKey("New recipe") : LocalizedStringKey(viewModel.recipe.name)) + Text(viewModel.recipe.name == "" ? String(localized: "New recipe") : viewModel.recipe.name) .font(.title) .bold() .padding() @@ -69,7 +92,16 @@ struct RecipeEditView: View { Section { TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importURL) Button { - viewModel.importRecipe() + Task { + if let res = await viewModel.importRecipe() { + alertType = RecipeAlert.CUSTOM( + title: res.localizedTitle, + description: res.localizedDescription + ) + alertAction = { } + presentAlert = true + } + } } label: { Text(LocalizedStringKey("Import")) } @@ -148,12 +180,12 @@ struct RecipeEditView: View { .onAppear { viewModel.prepareView() } - .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { - ForEach(viewModel.alertType.alertButtons) { buttonType in + .alert(alertType.localizedTitle, isPresented: $presentAlert) { + ForEach(alertType.alertButtons) { buttonType in if buttonType == .OK { Button(AlertButton.OK.rawValue, role: .cancel) { Task { - await viewModel.alertAction() + await alertAction() } } } else if buttonType == .CANCEL { @@ -161,15 +193,24 @@ struct RecipeEditView: View { } else if buttonType == .DELETE { Button(AlertButton.DELETE.rawValue, role: .destructive) { Task { - await viewModel.alertAction() + await alertAction() } } } } } message: { - Text(viewModel.alertType.localizedDescription) + Text(alertType.localizedDescription) } } + + func dismissEditView() { + Task { + await viewModel.mainViewModel.getCategories() + await viewModel.mainViewModel.getCategory(named: viewModel.recipe.recipeCategory, fetchMode: .preferServer) + await viewModel.mainViewModel.updateRecipeDetails(in: viewModel.recipe.recipeCategory) + } + self.isPresented = false + } } diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 616269e..87df692 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -11,8 +11,8 @@ import SwiftUI struct SettingsView: View { - @ObservedObject var userSettings: UserSettings @ObservedObject var viewModel: MainViewModel + @ObservedObject var userSettings = UserSettings.shared @State fileprivate var alertType: SettingsAlert = .NONE @State var showAlert: Bool = false @@ -20,9 +20,6 @@ struct SettingsView: View { var body: some View { Form { Section { - /*Toggle(isOn: $userSettings.downloadRecipes) { - Text("Always download new recipes") - }*/ Picker("Select a default cookbook", selection: $userSettings.defaultCategory) { Text("None").tag("None") ForEach(viewModel.categories, id: \.name) { category in @@ -35,6 +32,22 @@ struct SettingsView: View { Text("The selected cookbook will open on app launch by default.") } + Section { + Toggle(isOn: $userSettings.storeRecipes) { + Text("Offline recipes") + } + Toggle(isOn: $userSettings.storeImages) { + Text("Store recipe images locally") + } + Toggle(isOn: $userSettings.storeThumb) { + Text("Store recipe thumbnails locally") + } + } header: { + Text("Downloads") + } footer: { + Text("Configure what is stored on your device.") + } + Section { Picker("Language", selection: $userSettings.language) { ForEach(SupportedLanguage.allValues, id: \.self) { lang in @@ -102,6 +115,7 @@ struct SettingsView: View { userSettings.serverAddress = "" userSettings.username = "" userSettings.token = "" + userSettings.authString = "" viewModel.deleteAllData() userSettings.onboarding = true }