From 3504ce2a25d9de4aca6d842eb11a155be2b29a3b Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Sat, 16 Sep 2023 20:11:02 +0200 Subject: [PATCH] MainViewModel documentation and image caching improvements --- .../Data/DataModels.swift | 4 +- .../Data/DataStore.swift | 8 +- .../Nextcloud_Cookbook_iOS_ClientApp.swift | 2 +- .../ViewModels/MainViewModel.swift | 149 +++++++++++++----- .../Views/MainView.swift | 5 +- .../Views/OnboardingView.swift | 3 +- .../Views/RecipeCardView.swift | 4 +- .../Views/RecipeDetailView.swift | 7 +- .../Views/SettingsView.swift | 55 +++++-- 9 files changed, 166 insertions(+), 71 deletions(-) diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 7cf31bf..98105d4 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -63,6 +63,6 @@ struct RecipeDetail: Codable { } struct RecipeImage { - let thumb: UIImage - let full: UIImage? + var thumb: UIImage? + var full: UIImage? } diff --git a/Nextcloud Cookbook iOS Client/Data/DataStore.swift b/Nextcloud Cookbook iOS Client/Data/DataStore.swift index ef31634..65845c2 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataStore.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataStore.swift @@ -32,13 +32,17 @@ class DataStore { return try await task.value } - func save(data: D, toPath path: String) async throws { + func save(data: D, toPath path: String) async { let task = Task { let data = try JSONEncoder().encode(data) let outfile = try Self.fileURL(appending: path) try data.write(to: outfile) } - _ = try await task.value + do { + _ = try await task.value + } catch { + print("Could not save data (path: \(path)") + } } func clearAll() { diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index 909afb3..ad4e15d 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -12,7 +12,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App { @StateObject var userSettings = UserSettings() var body: some Scene { WindowGroup { - MainView() + MainView(userSettings: userSettings) .fullScreenCover(isPresented: $userSettings.onboarding) { OnboardingView(userSettings: userSettings) } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index fdc3d39..7da989b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -17,23 +17,45 @@ import UIKit let dataStore: DataStore let networkController: NetworkController + /// The path of an image in storage + private var localImagePath: (Int, Bool) -> (String) = { recipeId, full in + return "image\(recipeId)_\(full ? "full" : "thumb")" + } + + /// The path of an image on the server + private var networkImagePath: (Int, Bool) -> (String) = { recipeId, full in + return "recipes/\(recipeId)/image?size=\(full ? "full" : "thumb")" + } + init() { self.networkController = NetworkController() 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 load(localPath: "categories.data", networkPath: "categories", needsUpdate: needsUpdate) { self.categories = categoryList } } + /// Try to load the recipe list from store or the server. + /// - 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 { if let recipeList: [Recipe] = await load(localPath: "category_\(categoryName).data", networkPath: "category/\(categoryName)", needsUpdate: needsUpdate) { recipes[categoryName] = recipeList } } + /// 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] { @@ -47,50 +69,40 @@ import UIKit return RecipeDetail.error() } + + /// 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, full: Bool, needsUpdate: Bool = false) async -> UIImage? { - print("loadImage(recipeId: \(recipeId), full: \(full)") - - // Check if image is in image cache - if !needsUpdate, let recipeImage = imageCache[recipeId] { - if full { - if let fullImage = recipeImage.full { - return recipeImage.full - } - } else { - return recipeImage.thumb + print("loadImage(recipeId: \(recipeId), full: \(full))") + // If the image needs an update, request it from the server and overwrite the stored image + if needsUpdate { + if let data = await imageDataFromServer(recipeId: recipeId, full: full) { + guard let image = UIImage(data: data) else { return nil } + await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full)) + imageToCache(image: image, recipeId: recipeId, full: full) + return image } } - - // If image is not in image cache, request from local storage - do { - let localPath = "image\(recipeId)_\(full ? "full" : "thumb")" - if !needsUpdate, let data: String = try await dataStore.load(fromPath: localPath) { - print("Image data found locally. Decoding ...") - guard let dataDecoded = Data(base64Encoded: data) else { return nil } - print("Data to UIImage ...") - let image = UIImage(data: dataDecoded) - print("Done.") - return image - } else { - // If image is not in local storage, request from server - let networkPath = "recipes/\(recipeId)/image?size=full" - let request = RequestWrapper(method: .GET, path: networkPath, accept: .IMAGE) - let (data, error): (Data?, Error?) = try await networkController.sendHTTPRequest(path: request.path, request) - guard let data = data else { - print("Error receiving or decoding data.") - print("Error Message: \n", error) - return nil - } - let image = UIImage(data: data) - if image != nil { - print("Saving image loaclly ...") - try await dataStore.save(data: data.base64EncodedString(), toPath: localPath) - } - print("Done.") - return image - } - }catch { - print("An unknown error occurred.") + // Try to load image from cache + print("Attempting to load image from local storage ...") + if let image = imageFromCache(recipeId: recipeId, full: full) { + return image + } + // Try to load from store + print("Attempting to load image from server ...") + if let image = await imageFromStore(recipeId: recipeId, full: full) { + return image + } + // Try to load from the server. Store if successfull. + if let data = await imageDataFromServer(recipeId: recipeId, full: full) { + guard let image = UIImage(data: data) else { return nil } + await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full)) + imageToCache(image: image, recipeId: recipeId, full: full) + return image } return nil } @@ -108,7 +120,7 @@ extension MainViewModel { } else { let request = RequestWrapper(method: .GET, path: networkPath) let (data, error): (D?, Error?) = await networkController.sendDataRequest(request) - print(error) + print(error as Any) if let data = data { try await dataStore.save(data: data, toPath: localPath) } @@ -119,6 +131,59 @@ extension MainViewModel { } return nil } + + private func imageToCache(image: UIImage, recipeId: Int, full: Bool) { + if imageCache[recipeId] == nil { + imageCache[recipeId] = RecipeImage() + } + if full { + imageCache[recipeId]!.full = image + } else { + imageCache[recipeId]!.thumb = image + } + } + + private func imageFromCache(recipeId: Int, full: Bool) -> UIImage? { + if imageCache[recipeId] != nil { + if full { + return imageCache[recipeId]!.full + } else { + return imageCache[recipeId]!.thumb + } + } + return nil + } + + private func imageFromStore(recipeId: Int, full: Bool) async -> UIImage? { + do { + let localPath = localImagePath(recipeId, full) + if let data: String = try await dataStore.load(fromPath: localPath) { + guard let dataDecoded = Data(base64Encoded: data) else { return nil } + let image = UIImage(data: dataDecoded) + return image + } + } catch { + print("Could not find image in local storage.") + return nil + } + return nil + } + + private func imageDataFromServer(recipeId: Int, full: Bool) async -> Data? { + do { + let networkPath = networkImagePath(recipeId, full) + let request = RequestWrapper(method: .GET, path: networkPath, accept: .IMAGE) + let (data, _): (Data?, Error?) = try await networkController.sendHTTPRequest(path: request.path, request) + 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 + } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 9141aba..9947931 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -9,6 +9,7 @@ import SwiftUI struct MainView: View { @StateObject var viewModel = MainViewModel() + @StateObject var userSettings: UserSettings var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] var body: some View { NavigationStack { @@ -29,7 +30,7 @@ struct MainView: View { } .navigationTitle("CookBook") .toolbar { - NavigationLink( destination: SettingsView()) { + NavigationLink( destination: SettingsView(userSettings: userSettings)) { Image(systemName: "gear") } } @@ -45,7 +46,7 @@ struct MainView: View { struct MainView_Previews: PreviewProvider { static var previews: some View { - MainView() + MainView(userSettings: UserSettings()) } } diff --git a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift index 9eb36cc..f856c1e 100644 --- a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift +++ b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift @@ -146,7 +146,8 @@ struct LoginTextField: View { var body: some View { TextField(example, text: $text) .textFieldStyle(.plain) - .textCase(.lowercase) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .foregroundColor(.white) .accentColor(.white) .padding() diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index 6ddc704..8cd3790 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -16,7 +16,9 @@ struct RecipeCardView: View { HStack { Image(uiImage: recipeThumb ?? UIImage(named: "CookBook")!) .resizable() - .frame(maxWidth: 80, maxHeight: 80) + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipped() Text(recipe.name) .font(.headline) diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 34d306e..0043c9e 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -25,8 +25,8 @@ struct RecipeDetailView: View { .frame(height: 300) .clipped() } else { - Color.blue - .frame(height: 300) + Color("ncblue") + .frame(height: 150) } if let recipeDetail = recipeDetail { @@ -80,8 +80,7 @@ struct RecipeYieldSection: View { Text("Servings: \(recipeDetail.recipeYield)") Spacer() }.padding() - .background(Color("accent")) - .clipShape(RoundedRectangle(cornerRadius: 10)) + } } diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index c7a1908..31bb2c6 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -9,13 +9,14 @@ import Foundation import SwiftUI struct SettingsView: View { - @StateObject var userSettings = UserSettings() + @ObservedObject var userSettings: UserSettings var body: some View { - ScrollView(showsIndicators: false) { - LazyVStack { - SettingsSection(headline: "Language", description: "Language settings coming soon.") - SettingsSection(headline: "Accent Color", description: "The accent color setting will be released in a future update.") + List { + SettingsSection(title: "Language", description: "Language settings coming soon.") + SettingsSection(title: "Accent Color", description: "The accent color setting will be released in a future update.") + SettingsSection(title: "Log out", description: "Log out of your Nextcloud account in this app. Your recipes will be removed from local storage.") + { Button("Log out") { print("Log out.") userSettings.serverAddress = "" @@ -26,7 +27,10 @@ struct SettingsView: View { .buttonStyle(.borderedProminent) .accentColor(.red) .padding() - + } + + SettingsSection(title: "Clear local data", description: "Your recipes will be removed from local storage.") + { Button("Clear Cache") { print("Clear cache.") @@ -35,21 +39,40 @@ struct SettingsView: View { .accentColor(.red) .padding() } + }.navigationTitle("Settings") } } -struct SettingsSection: View { - @State var headline: String - @State var description: String +struct SettingsSection: View { + let title: String + let description: String + @ViewBuilder let content: () -> Content + + init(title: String, description: String, content: @escaping () -> Content) { + self.title = title + self.description = description + self.content = content + } + + init(title: String, description: String) where Content == EmptyView { + self.title = title + self.description = description + self.content = { EmptyView() } + } + var body: some View { - VStack(alignment: .leading) { - Text(headline) - .font(.headline) - Text(description) - - Divider() - }.padding() + HStack { + VStack(alignment: .leading) { + Text(title) + .font(.headline) + Text(description) + .font(.caption) + }.padding() + Spacer() + content() + } + } }