Merge pull request #12 from VincentMeilinger/download-rework

API rework
This commit is contained in:
VincentM
2023-12-10 11:07:54 +01:00
committed by GitHub
26 changed files with 1461 additions and 907 deletions

View File

@@ -36,8 +36,11 @@
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; }; A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */; }; A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.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 */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; };
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; }; A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; };
@@ -94,8 +97,11 @@
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; }; A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; }; A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; }; A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiRequest.swift; sourceTree = "<group>"; };
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; }; A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; }; A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; };
@@ -195,6 +201,8 @@
A70171B22AB211F000064C43 /* Network */ = { A70171B22AB211F000064C43 /* Network */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A703226C2ABAF90D00D7C4ED /* APIController.swift */, A703226C2ABAF90D00D7C4ED /* APIController.swift */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
@@ -220,7 +228,7 @@
A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
A79AA8EF2B0D0B5F007D25F2 /* Onboarding */, A70171C82AB4CBB400064C43 /* OnboardingView.swift */,
A70171CC2AB501B100064C43 /* SettingsView.swift */, A70171CC2AB501B100064C43 /* SettingsView.swift */,
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
@@ -245,6 +253,7 @@
children = ( children = (
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */,
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -265,14 +274,22 @@
path = RecipeImport; path = RecipeImport;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A79AA8EF2B0D0B5F007D25F2 /* Onboarding */ = { A79AA8E72B062DB6007D25F2 /* CookbookApi */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70171C82AB4CBB400064C43 /* OnboardingView.swift */, A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */, A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */, A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
); );
path = Onboarding; path = CookbookApi;
sourceTree = "<group>";
};
A79AA8EE2B063B33007D25F2 /* NextcloudApi */ = {
isa = PBXGroup;
children = (
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */,
);
path = NextcloudApi;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXGroup section */ /* End PBXGroup section */
@@ -419,28 +436,31 @@
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */,
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */,
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */, A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */,
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */, A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */,
A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */,
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@@ -146,6 +146,9 @@ extension RecipeDetail {
struct RecipeImage { struct RecipeImage {
enum RecipeImageSize {
case THUMB, FULL
}
var imageExists: Bool = true var imageExists: Bool = true
var thumb: UIImage? var thumb: UIImage?
var full: UIImage? var full: UIImage?
@@ -193,7 +196,4 @@ struct MetaData: Codable {
} }
// Networking
struct ServerMessage: Decodable {
let msg: String
}

View File

@@ -22,6 +22,12 @@ class UserSettings: ObservableObject {
} }
} }
@Published var authString: String {
didSet {
UserDefaults.standard.set(authString, forKey: "authString")
}
}
@Published var serverAddress: String { @Published var serverAddress: String {
didSet { didSet {
UserDefaults.standard.set(serverAddress, forKey: "serverAddress") UserDefaults.standard.set(serverAddress, forKey: "serverAddress")
@@ -55,12 +61,29 @@ class UserSettings: ObservableObject {
init() { init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") 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.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? ""
self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true
self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? ""
self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue
self.downloadRecipes = UserDefaults.standard.object(forKey: "downloadRecipes") as? Bool ?? false 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 {
if token != "" && username != "" {
let loginString = "\(self.username):\(self.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
return loginData.base64EncodedString()
} else {
return ""
}
}
} }

View File

@@ -11,11 +11,9 @@ extension JSONDecoder {
static func safeDecode<T: Decodable>(_ data: Data) -> T? { static func safeDecode<T: Decodable>(_ data: Data) -> T? {
let decoder = JSONDecoder() let decoder = JSONDecoder()
do { do {
print("Decoding type ", T.self, " ...")
return try decoder.decode(T.self, from: data) return try decoder.decode(T.self, from: data)
} catch (let error) { } catch (let error) {
print("JSONDecoder - safeDecode(): Failed to decode data.") print(error)
print("Error: ", error)
return nil return nil
} }
} }

View File

@@ -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")
}

View File

@@ -292,6 +292,12 @@
} }
} }
} }
},
"Action completed." : {
},
"Action delayed" : {
}, },
"Add" : { "Add" : {
"localizations" : { "localizations" : {
@@ -622,6 +628,9 @@
} }
} }
} }
},
"Could not establish a connection to the server. The action will be retried upon reconnection." : {
}, },
"Delete" : { "Delete" : {
"localizations" : { "localizations" : {
@@ -844,7 +853,6 @@
} }
}, },
"Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'." : { "Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -865,6 +873,9 @@
} }
} }
} }
},
"Error" : {
}, },
"Error." : { "Error." : {
"localizations" : { "localizations" : {
@@ -953,9 +964,6 @@
} }
} }
} }
},
"If the login button does not open your browser, copy the following link and paste it in your browser manually:" : {
}, },
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : { "If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
"localizations" : { "localizations" : {
@@ -1226,9 +1234,6 @@
} }
} }
} }
},
"Login" : {
}, },
"Login failed." : { "Login failed." : {
"localizations" : { "localizations" : {
@@ -1273,9 +1278,6 @@
} }
} }
} }
},
"Make sure to enter the server address in the form 'example.com'. Currently, only servers using the 'https' protocol are supported." : {
}, },
"Missing recipe name." : { "Missing recipe name." : {
"localizations" : { "localizations" : {
@@ -1498,7 +1500,6 @@
} }
}, },
"Open in browser" : { "Open in browser" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1695,9 +1696,6 @@
} }
} }
} }
},
"Pull to refresh." : {
}, },
"Same as Device" : { "Same as Device" : {
"localizations" : { "localizations" : {
@@ -1852,9 +1850,6 @@
} }
} }
} }
},
"Show help" : {
}, },
"Submit" : { "Submit" : {
"localizations" : { "localizations" : {
@@ -1877,6 +1872,9 @@
} }
} }
} }
},
"Success" : {
}, },
"Support" : { "Support" : {
"localizations" : { "localizations" : {
@@ -1921,9 +1919,6 @@
} }
} }
} }
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : {
}, },
"The selected cookbook will open on app launch by default." : { "The selected cookbook will open on app launch by default." : {
"localizations" : { "localizations" : {
@@ -2100,6 +2095,9 @@
} }
} }
} }
},
"Unable to complete action." : {
}, },
"Unable to connect to server." : { "Unable to connect to server." : {
"localizations" : { "localizations" : {

View File

@@ -6,23 +6,21 @@
// //
import Foundation import Foundation
import SwiftUI
class APIController { class APIController {
@AppStorage("serverAddress") var serverAddress = "" var userSettings: UserSettings
@AppStorage("username") var username = ""
@AppStorage("token") var token = ""
var apiPath: String = "" var apiPath: String
var authString: String = "" var authString: String
let apiVersion = "1" let apiVersion = "1"
init() { init(userSettings: UserSettings) {
print("Initializing APIController.") print("Initializing APIController.")
self.userSettings = userSettings
self.apiPath = "https://\(serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
let loginString = "\(username):\(token)" let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)! let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString() self.authString = loginData.base64EncodedString()
} }

View File

@@ -0,0 +1,104 @@
//
// 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 = "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) }
// 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!")
if let data = data {
print(data, String(data: data, encoding: .utf8))
}
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)
}
}
}

View File

@@ -0,0 +1,192 @@
//
// 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,
recipe: RecipeDetail
) 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,
recipe: RecipeDetail
) 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 -> ([Category]?, 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?)
}

View File

@@ -0,0 +1,216 @@
//
// 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, 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)],
body: recipeData
)
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, recipe: RecipeDetail) async -> (NetworkError?) {
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
let request = ApiRequest(
serverAdress: serverAdress,
path: "/api/v1/recipes/\(recipe.id)",
method: .PUT,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)],
body: recipeData
)
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 -> ([Category]?, 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
}
}

View File

@@ -18,6 +18,7 @@ public enum NetworkError: String, Error {
case missingUrl = "Missing URL." case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil." case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed." case encodingFailed = "Parameter encoding failed."
case decodingFailed = "Data decoding failed."
case redirectionError = "Redirection error" case redirectionError = "Redirection error"
case clientError = "Client error" case clientError = "Client error"
case serverError = "Server error" case serverError = "Server error"
@@ -25,3 +26,4 @@ public enum NetworkError: String, Error {
case unknownError = "Unknown error" case unknownError = "Unknown error"
case dataError = "Invalid data error." case dataError = "Invalid data error."
} }

View File

@@ -14,35 +14,7 @@ enum RequestMethod: String {
DELETE = "DELETE" 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 { enum ContentType: String {
case JSON = "application/json", 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 { struct RequestWrapper {
private let _method: RequestMethod private let _method: RequestMethod
private let _path: RequestPath private let _path: RequestPath

View File

@@ -0,0 +1,12 @@
//
// NextcloudApi.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
class NextcloudApi {
}

View File

@@ -9,27 +9,22 @@ import SwiftUI
@main @main
struct Nextcloud_Cookbook_iOS_ClientApp: App { struct Nextcloud_Cookbook_iOS_ClientApp: App {
@StateObject var mainViewModel = MainViewModel() @StateObject var userSettings = UserSettings()
@AppStorage("onboarding") var onboarding = true
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ZStack { ZStack {
if onboarding { if userSettings.onboarding {
OnboardingView() OnboardingView(userSettings: userSettings)
} else { } else {
MainView(viewModel: mainViewModel) MainView(userSettings: userSettings)
.onAppear {
mainViewModel.apiController = APIController()
}
} }
} }
.transition(.slide) .transition(.slide)
.environment( .environment(
\.locale, \.locale,
.init(identifier: language == .init(identifier: userSettings.language ==
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language) SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : userSettings.language)
) )
} }
} }

View File

@@ -11,65 +11,127 @@ import UIKit
@MainActor class MainViewModel: ObservableObject { @MainActor class MainViewModel: ObservableObject {
@AppStorage("authString") var authString = ""
@AppStorage("serverAddress") var serverAdress = ""
@Published var categories: [Category] = [] @Published var categories: [Category] = []
@Published var recipes: [String: [Recipe]] = [:] @Published var recipes: [String: [Recipe]] = [:]
private var recipeDetails: [Int: RecipeDetail] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:]
private var imageCache: [Int: RecipeImage] = [:] private var requestQueue: [RequestWrapper] = []
let dataStore: DataStore private let api: CookbookApi.Type
var apiController: APIController? = nil private let dataStore: DataStore
/// The path of an image in storage init(apiVersion api: CookbookApi.Type = CookbookApiV1.self) {
private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in print("Created MainViewModel")
return "image\(recipeId)_\(thumb ? "thumb" : "full")" self.api = api
}
/// 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() {
self.dataStore = DataStore() self.dataStore = DataStore()
} }
/// Try to load the category list from store or the server. enum FetchMode {
/// - Parameters case preferLocal, preferServer, onlyLocal, onlyServer
/// - 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)
} }
/// 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 Asynchronously loads and updates the list of categories.
/// - 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. 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.
func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async {
let categoryString = categoryName == "*" ? "_" : categoryName - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
if let recipeList: [Recipe] = await loadObject( */
localPath: "category_\(categoryString).data", func getCategories() async {
networkPath: .RECIPE_LIST(categoryName: categoryString), let (categories, _) = await api.getCategories(
needsUpdate: needsUpdate from: serverAdress,
) { auth: authString
recipes[categoryName] = recipeList )
print(recipeList) if let categories = categories {
self.categories = categories
await saveLocal(categories, path: "categories.data")
} 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
}
}
} }
/**
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 getAllRecipes() async -> [Recipe] { 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 }
}
}
/**
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,
auth: authString
)
if let recipes = recipes {
return recipes
} else if let error = error {
print(error)
}
var allRecipes: [Recipe] = [] var allRecipes: [Recipe] = []
for category in categories { for category in categories {
await loadRecipeList(categoryName: category.name) if let recipeArray = self.recipes[category.name] {
if let recipeArray = recipes[category.name] {
allRecipes.append(contentsOf: recipeArray) allRecipes.append(contentsOf: recipeArray)
} }
} }
@@ -78,39 +140,93 @@ import UIKit
}) })
} }
/// Try to load the recipe details from cache. If not found, try to load from store or the server. /**
/// - Parameters Asynchronously retrieves a recipe detail either from the server or locally cached data.
/// - 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. 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.
/// - 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 { - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
if !needsUpdate {
if let recipeDetail = recipeDetails[recipeId] { - Parameters:
return recipeDetail - 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
}
return RecipeDetail.error
} }
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
}
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 }
}
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 { func downloadAllRecipes() async {
for category in categories { for category in categories {
await loadRecipeList(categoryName: category.name, needsUpdate: true) await getCategory(named: category.name, fetchMode: .onlyServer)
guard let recipeList = recipes[category.name] else { continue } guard let recipeList = recipes[category.name] else { continue }
for recipe in recipeList { for recipe in recipeList {
let _ = await loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) let recipeDetail = await getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer)
let _ = await loadImage(recipeId: recipe.recipe_id, thumb: true) 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")
} }
} }
} }
/// Check if recipeDetail is stored locally, either in cache or on disk /// Check if recipeDetail is stored locally, either in cache or on disk
/// - Parameters /// - Parameters
/// - recipeId: The id of a recipe. /// - recipeId: The id of a recipe.
@@ -124,78 +240,88 @@ import UIKit
return false return false
} }
/**
Asynchronously retrieves and returns an image for a recipe with the specified ID and size.
/// Try to load the recipe image from cache. If not found, try to load from store or the server. 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.
/// - Parameters
/// - recipeId: The id of a recipe. - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
/// - 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. - Parameters:
/// - Returns: The image if found locally or on the server, otherwise nil. - id: The identifier of the recipe associated with the image.
func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { - size: The size of the desired image (thumbnail or full).
print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))") - needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage.
// If the image needs an update, request it from the server and overwrite the stored image
if needsUpdate { Example usage:
guard let apiController = apiController else { return nil } ```swift
if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) { let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true)
guard let image = UIImage(data: data) else { */
imageCache[recipeId] = RecipeImage(imageExists: false) func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
return nil func getLocal() async -> UIImage? {
} return await imageFromStore(id: id, size: size)
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
}
} }
// Check imageExists flag to detect if we attempted to load a non-existing image before. func getServer() async -> UIImage? {
// This allows us to avoid sending requests to the server if we already know the recipe has no image. let (image, _) = await api.getImage(
if imageCache[recipeId] != nil { from: serverAdress,
guard imageCache[recipeId]!.imageExists else { return nil } auth: authString,
} id: id,
size: size
// Try to load image from cache )
print("Attempting to load image from cache ...") if let image = image { return image }
if let image = imageFromCache(recipeId: recipeId, thumb: thumb) {
print("Image found in cache.")
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 return nil
} }
func getKeywords() async -> [String] { switch fetchMode {
if let keywords: [RecipeKeyword] = await self.loadObject( case .preferLocal:
localPath: "keywords.data", if let image = await getLocal() { return image }
networkPath: .KEYWORDS, if let image = await getServer() { return image }
needsUpdate: true case .preferServer:
) { if let image = await getServer() { return image }
return keywords.map { $0.name } 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 }
}
return nil
}
/**
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")
}
func getServer() async -> [String]? {
let (tags, _) = await api.getTags(
from: serverAdress,
auth: authString
)
return tags
}
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 [] return []
} }
@@ -204,35 +330,105 @@ import UIKit
if dataStore.clearAll() { if dataStore.clearAll() {
self.categories = [] self.categories = []
self.recipes = [:] self.recipes = [:]
self.imageCache = [:]
self.recipeDetails = [:] self.recipeDetails = [:]
self.requestQueue = []
} }
} }
func deleteRecipe(withId id: Int, categoryName: String) { /**
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,
auth: authString,
id: id
)
if let error = error {
return .REQUEST_DROPPED
}
let path = "recipe\(id).data" let path = "recipe\(id).data"
dataStore.delete(path: path) dataStore.delete(path: path)
guard recipes[categoryName] != nil else { return } if recipes[categoryName] != nil {
recipes[categoryName]!.removeAll(where: { recipe in recipes[categoryName]!.removeAll(where: { recipe in
recipe.recipe_id == id ? true : false recipe.recipe_id == id ? true : false
}) })
recipeDetails.removeValue(forKey: id) recipeDetails.removeValue(forKey: id)
} }
return .REQUEST_SUCCESS
}
/**
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 { func checkServerConnection() async -> Bool {
guard let apiController = apiController else { return false } let (categories, _) = await api.getCategories(
let req = RequestWrapper.customRequest( from: serverAdress,
method: .GET, auth: authString
path: .CONFIG,
headerFields: [
.ocsRequest(value: true),
.accept(value: .JSON)
]
) )
if let error = await apiController.sendRequest(req) { if let categories = categories {
self.categories = categories
await saveLocal(categories, path: "categories.data")
return true
}
return false return false
} }
return true
/**
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 {
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
} }
} }
@@ -240,54 +436,23 @@ import UIKit
extension MainViewModel { extension MainViewModel {
private func loadObject<T: Codable>(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { func loadLocal<T: Codable>(path: String) async -> T? {
do { do {
if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) { return try await dataStore.load(fromPath: path)
print("Data found locally.") } catch (let error) {
return data print(error)
} 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 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? { func saveLocal<T: Codable>(_ object: T, path: String) async {
if imageCache[recipeId] != nil { guard let data = JSONEncoder.safeEncode(object) else { return }
if thumb { await dataStore.save(data: data, toPath: path)
return imageCache[recipeId]!.thumb
} else {
return imageCache[recipeId]!.full
}
}
return nil
} }
private func imageFromStore(recipeId: Int, thumb: Bool) async -> UIImage? { private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? {
do { do {
let localPath = localImagePath(recipeId, thumb) let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")"
if let data: String = try await dataStore.load(fromPath: localPath) { if let data: String = try await dataStore.load(fromPath: localPath) {
guard let dataDecoded = Data(base64Encoded: data) else { return nil } guard let dataDecoded = Data(base64Encoded: data) else { return nil }
let image = UIImage(data: dataDecoded) let image = UIImage(data: dataDecoded)

View File

@@ -26,7 +26,7 @@ import SwiftUI
@Published var presentAlert = false @Published var presentAlert = false
var alertType: UserAlert = RecipeCreationError.GENERIC var alertType: UserAlert = RecipeCreationError.GENERIC
var alertAction: @MainActor () -> () = {} var alertAction: @MainActor () async -> (RequestAlert) = { return .REQUEST_DROPPED }
var uploadNew: Bool = true var uploadNew: Bool = true
var waitingForUpload: Bool = false var waitingForUpload: Bool = false
@@ -57,7 +57,7 @@ import SwiftUI
// Check if the recipe has a name // Check if the recipe has a name
if recipe.name.replacingOccurrences(of: " ", with: "") == "" { if recipe.name.replacingOccurrences(of: " ", with: "") == "" {
alertType = RecipeCreationError.NO_TITLE alertType = RecipeCreationError.NO_TITLE
alertAction = {} alertAction = {return .REQUEST_DROPPED}
presentAlert = true presentAlert = true
return false return false
} }
@@ -72,7 +72,7 @@ import SwiftUI
.lowercased() .lowercased()
{ {
alertType = RecipeCreationError.DUPLICATE alertType = RecipeCreationError.DUPLICATE
alertAction = {} alertAction = {return .REQUEST_DROPPED}
presentAlert = true presentAlert = true
return false return false
} }
@@ -82,79 +82,37 @@ import SwiftUI
return true return true
} }
func uploadNewRecipe() { func uploadNewRecipe() async -> RequestAlert {
print("Uploading new recipe.") print("Uploading new recipe.")
waitingForUpload = true waitingForUpload = true
createRecipe() createRecipe()
guard recipeValid() else { return } guard recipeValid() else { return .REQUEST_DROPPED }
let request = RequestWrapper.customRequest(
method: .POST, return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
path: .NEW_RECIPE,
headerFields: [
HeaderField.accept(value: .JSON),
HeaderField.ocsRequest(value: true),
HeaderField.contentType(value: .JSON)
],
body: JSONEncoder.safeEncode(self.recipe)
)
sendRequest(request)
dismissEditView()
} }
func uploadEditedRecipe() { func uploadEditedRecipe() async -> RequestAlert {
waitingForUpload = true waitingForUpload = true
print("Uploading changed recipe.") print("Uploading changed recipe.")
guard let recipeId = Int(recipe.id) else { return } guard let recipeId = Int(recipe.id) else { return .REQUEST_DROPPED }
createRecipe() createRecipe()
let request = RequestWrapper.customRequest(
method: .PUT, return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
path: .RECIPE_DETAIL(recipeId: recipeId),
headerFields: [
HeaderField.accept(value: .JSON),
HeaderField.ocsRequest(value: true),
HeaderField.contentType(value: .JSON)
],
body: JSONEncoder.safeEncode(self.recipe)
)
sendRequest(request)
dismissEditView()
} }
func deleteRecipe() { func deleteRecipe() async -> RequestAlert {
guard let recipeId = Int(recipe.id) else { return } guard let id = Int(recipe.id) else {
let request = RequestWrapper.customRequest( return .REQUEST_DROPPED
method: .DELETE,
path: .RECIPE_DETAIL(recipeId: recipeId),
headerFields: [
HeaderField.accept(value: .JSON),
HeaderField.ocsRequest(value: true)
]
)
sendRequest(request)
if let recipeIdInt = Int(recipe.id) {
mainViewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory)
} }
dismissEditView() 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() { func dismissEditView() {
Task { Task {
await mainViewModel.loadCategoryList(needsUpdate: true) await mainViewModel.getCategories()
await mainViewModel.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) await mainViewModel.getCategory(named: recipe.recipeCategory, fetchMode: .preferServer)
} }
isPresented.wrappedValue = false isPresented.wrappedValue = false
} }
@@ -182,7 +140,7 @@ import SwiftUI
} }
if let error = error { if let error = error {
self.alertType = error self.alertType = error
self.alertAction = {} self.alertAction = {return .REQUEST_DROPPED}
self.presentAlert = true self.presentAlert = true
} }
} catch { } catch {

View File

@@ -109,3 +109,30 @@ enum RecipeImportError: UserAlert {
return [.OK] 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]
}
}

View File

@@ -61,10 +61,10 @@ struct CategoryDetailView: View {
} }
.searchable(text: $searchText, prompt: "Search recipes") .searchable(text: $searchText, prompt: "Search recipes")
.task { .task {
await viewModel.loadRecipeList(categoryName: categoryName) await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal)
} }
.refreshable { .refreshable {
await viewModel.loadRecipeList(categoryName: categoryName, needsUpdate: true) await viewModel.getCategory(named: categoryName, fetchMode: .preferServer)
} }
} }
@@ -79,13 +79,20 @@ struct CategoryDetailView: View {
func downloadRecipes() { func downloadRecipes() {
if let recipes = viewModel.recipes[categoryName] { if let recipes = viewModel.recipes[categoryName] {
let dispatchQueue = DispatchQueue(label: "RecipeDownload", qos: .background)
dispatchQueue.async {
for recipe in recipes {
Task { Task {
let _ = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) for recipe in recipes {
let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) 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")
} }
} }
} }

View File

@@ -9,8 +9,8 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@ObservedObject var viewModel: MainViewModel @ObservedObject var userSettings: UserSettings
@StateObject var userSettings: UserSettings = UserSettings() @StateObject var viewModel = MainViewModel()
@State private var selectedCategory: Category? = nil @State private var selectedCategory: Category? = nil
@State private var showEditView: Bool = false @State private var showEditView: Bool = false
@@ -37,10 +37,6 @@ struct MainView: View {
.padding(7) .padding(7)
} }
if viewModel.categories.isEmpty {
Text("Pull to refresh.")
}
// Categories // Categories
ForEach(viewModel.categories) { category in ForEach(viewModel.categories) { category in
if category.recipe_count != 0 { if category.recipe_count != 0 {
@@ -94,7 +90,7 @@ struct MainView: View {
} }
.task { .task {
self.serverConnection = await viewModel.checkServerConnection() self.serverConnection = await viewModel.checkServerConnection()
await viewModel.loadCategoryList() await viewModel.getCategories()//viewModel.loadCategoryList()
// Open detail view for default category // Open detail view for default category
if userSettings.defaultCategory != "" { if userSettings.defaultCategory != "" {
if let cat = viewModel.categories.first(where: { c in if let cat = viewModel.categories.first(where: { c in
@@ -109,7 +105,7 @@ struct MainView: View {
} }
.refreshable { .refreshable {
self.serverConnection = await viewModel.checkServerConnection() self.serverConnection = await viewModel.checkServerConnection()
await viewModel.loadCategoryList(needsUpdate: true) await viewModel.getCategories()//loadCategoryList(needsUpdate: true)
} }
} }
@@ -212,7 +208,7 @@ struct RecipeSearchView: View {
.navigationTitle("Search recipe") .navigationTitle("Search recipe")
} }
.task { .task {
allRecipes = await viewModel.getAllRecipes() allRecipes = await viewModel.getRecipes()//.getAllRecipes()
} }
} }

View File

@@ -1,159 +0,0 @@
//
// OnboardingView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct OnboardingView: View {
@State var selectedTab: Int = 0
var body: some View {
TabView(selection: $selectedTab) {
WelcomeTab().tag(0)
LoginTab().tag(1)
}
.tabViewStyle(.page)
.background(
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
)
.animation(.easeInOut, value: selectedTab)
}
}
struct WelcomeTab: View {
var body: some View {
VStack(alignment: .center) {
Spacer()
Image("cookbook-icon")
.resizable()
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text("Thank you for downloading")
.font(.headline)
Text("Cookbook Client")
.font(.largeTitle)
.bold()
Spacer()
Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.")
.padding()
Spacer()
}
.padding()
.fontDesign(.rounded)
}
}
protocol LoginStage {
func next() -> Self
func previous() -> Self
}
enum LoginMethod {
case v2, token
}
enum TokenLoginStage: LoginStage {
case serverAddress, userName, appToken, validate
func next() -> TokenLoginStage {
switch self {
case .serverAddress: return .userName
case .userName: return .appToken
case .appToken: return .validate
case .validate: return .validate
}
}
func previous() -> TokenLoginStage {
switch self {
case .serverAddress: return .serverAddress
case .userName: return .serverAddress
case .appToken: return .userName
case .validate: return .appToken
}
}
}
struct LoginTab: View {
@State var loginMethod: LoginMethod = .v2
// Login error alert
@State var showAlert: Bool = false
@State var alertMessage: String = "Error: Could not connect to server."
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
Spacer()
Picker("Login Method", selection: $loginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token)
}
.pickerStyle(.segmented)
.foregroundColor(.white)
.padding()
if loginMethod == .token {
TokenLoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
}
else if loginMethod == .v2 {
V2LoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
}
Spacer()
}
.fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
}
}
struct LoginLabel: View {
let text: String
var body: some View {
Text(text)
.foregroundColor(.white)
.font(.headline)
.padding(.vertical, 5)
}
}
struct LoginTextField: View {
var example: String
@Binding var text: String
@State var color: Color = .white
var body: some View {
TextField(example, text: $text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.foregroundColor(color)
.accentColor(color)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(color, lineWidth: 2)
.foregroundColor(.clear)
)
}
}

View File

@@ -1,131 +0,0 @@
//
// TokenLoginView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 21.11.23.
//
import Foundation
import SwiftUI
struct TokenLoginView: View {
@Binding var showAlert: Bool
@Binding var alertMessage: String
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = false
// TextField handling
enum Field {
case server
case username
case token
}
var body: some View {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
LoginTextField(example: "e.g.: example.com", text: $serverAddress)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "User name")
LoginTextField(example: "username", text: $userName)
.focused($focusedField, equals: .username)
.textContentType(.username)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "App Token")
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
HStack{
Spacer()
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
onboarding = false
}
}
} label: {
Text("Submit")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.padding()
Spacer()
}
}
.onSubmit {
switch focusedField {
case .server:
focusedField = .username
case .username:
focusedField = .token
default:
print("Attempting to log in ...")
}
}
}
func loginCheck(nextcloudLogin: Bool) async -> Bool {
if serverAddress == "" {
alertMessage = "Please enter a server address!"
showAlert = true
return false
} else if !nextcloudLogin && (userName == "" || token == "") {
alertMessage = "Please enter a user name and app token!"
showAlert = true
return false
}
let headerFields = [
HeaderField.ocsRequest(value: true),
]
let request = RequestWrapper.customRequest(
method: .GET,
path: .CATEGORIES,
headerFields: headerFields,
authenticate: true
)
var (data, error): (Data?, Error?) = (nil, nil)
do {
let loginString = "\(userName):\(token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
let authString = loginData.base64EncodedString()
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: "https://\(serverAddress)/index.php/apps/cookbook/api/v1/",
authString: authString
)
} catch {
print("Error: ", error)
}
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
if let testRequest: [Category] = JSONDecoder.safeDecode(data) {
print("validationResponse: \(testRequest)")
return true
}
alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true
return false
}
}

View File

@@ -1,250 +0,0 @@
//
// V2LoginView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 21.11.23.
//
import Foundation
import SwiftUI
enum V2LoginStage: LoginStage {
case serverAddress, login, validate
func next() -> V2LoginStage {
switch self {
case .serverAddress: return .login
case .login: return .validate
case .validate: return .validate
}
}
func previous() -> V2LoginStage {
switch self {
case .serverAddress: return .serverAddress
case .login: return .serverAddress
case .validate: return .login
}
}
}
struct CollapsibleView<T: View>: View {
@State var titleColor: Color = .white
@State var content: () -> T
@State var title: () -> Text
@State var isCollapsed: Bool = true
@State var rotationAngle: Double = -90
var body: some View {
VStack(alignment: .leading) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCollapsed.toggle()
if isCollapsed {
rotationAngle += 90
} else {
rotationAngle -= 90
}
}
rotationAngle = isCollapsed ? -90 : 0
} label: {
HStack {
Image(systemName: "chevron.down")
.bold()
.rotationEffect(Angle(degrees: rotationAngle))
title()
}.foregroundStyle(titleColor)
}
if !isCollapsed {
content()
.padding(.top, 1)
}
}
}
}
struct V2LoginView: View {
@Binding var showAlert: Bool
@Binding var alertMessage: String
@State var loginStage: V2LoginStage = .serverAddress
@State var loginRequest: LoginV2Request? = nil
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = true
// TextField handling
enum Field {
case server
case username
case token
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
.padding()
LoginTextField(example: "e.g.: example.com", text: $serverAddress, color: loginStage == .serverAddress ? .white : .secondary)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.done)
.padding([.bottom, .horizontal])
.onSubmit {
withAnimation(.easeInOut) {
loginStage = loginStage.next()
}
}
CollapsibleView {
VStack(alignment: .leading) {
Text("Make sure to enter the server address in the form 'example.com'. Currently, only servers using the 'https' protocol are supported.")
if let loginRequest = loginRequest {
Text("If the login button does not open your browser, copy the following link and paste it in your browser manually:")
Text(loginRequest.login)
}
}
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding()
if loginStage == .login || loginStage == .validate {
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.")
.font(.subheadline)
.foregroundStyle(.white)
.padding()
}
HStack {
if loginStage == .login || loginStage == .validate {
Button {
if serverAddress == "" {
alertMessage = "Please enter a valid server address."
showAlert = true
return
}
Task {
await sendLoginV2Request()
if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
}
if loginStage == .validate {
Spacer()
Button {
// fetch login v2 response
Task {
guard let res = await fetchLoginV2Response() else {
alertMessage = "Login failed. Please login via the browser and try again."
showAlert = true
return
}
print("Login successfull for user \(res.loginName)!")
self.userName = res.loginName
self.token = res.appPassword
self.onboarding = false
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.disabled(loginRequest == nil ? true : false)
.padding()
}
}
}
}
}
func sendLoginV2Request() async {
let hostPath = "https://\(serverAddress)"
let headerFields: [HeaderField] = [
//HeaderField.ocsRequest(value: true),
//HeaderField.accept(value: .JSON)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: .LOGINV2REQ,
headerFields: headerFields
)
do {
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: hostPath,
authString: nil
)
guard let data = data else { return }
print("Data: \(data)")
let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data)
self.loginRequest = loginReq
} catch {
print("Could not establish communication with the server.")
}
}
func fetchLoginV2Response() async -> LoginV2Response? {
guard let loginRequest = loginRequest else { return nil }
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: .NONE,
headerFields: headerFields,
body: "token=\(loginRequest.poll.token)".data(using: .utf8),
authenticate: false
)
var (data, error): (Data?, Error?) = (nil, nil)
do {
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: loginRequest.poll.endpoint,
authString: nil
)
} catch {
print("Error: ", error)
}
guard let data = data else { return nil }
if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) {
return loginRes
}
print("Could not decode.")
return nil
}
}

View File

@@ -0,0 +1,355 @@
//
// OnboardingView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct OnboardingView: View {
@ObservedObject var userSettings: UserSettings
@State var selectedTab: Int = 0
var body: some View {
TabView(selection: $selectedTab) {
WelcomeTab().tag(0)
LoginTab(userSettings: userSettings).tag(1)
}
.tabViewStyle(.page)
.background(
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
)
.animation(.easeInOut, value: selectedTab)
}
}
struct WelcomeTab: View {
var body: some View {
VStack(alignment: .center) {
Spacer()
Image("cookbook-icon")
.resizable()
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text("Thank you for downloading")
.font(.headline)
Text("Cookbook Client")
.font(.largeTitle)
.bold()
Spacer()
Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.")
.padding()
Spacer()
}
.padding()
.fontDesign(.rounded)
}
}
struct LoginTab: View {
@ObservedObject var userSettings: UserSettings
// Login flow
enum LoginMethod {
case v2, token
}
@State var selectedLoginMethod: LoginMethod = .v2
@State var loginRequest: LoginV2Request? = nil
// Login error alert
@State var showAlert: Bool = false
@State var alertMessage: String = "Error: Could not connect to server."
// TextField handling
enum Field {
case server
case username
case token
}
@FocusState private var focusedField: Field?
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
Spacer()
Picker("Login Method", selection: $selectedLoginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token)
}
.pickerStyle(.segmented)
.foregroundColor(.white)
if selectedLoginMethod == .token {
LoginLabel(text: "Server address")
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "User name")
LoginTextField(example: "username", text: $userSettings.username)
.focused($focusedField, equals: .username)
.textContentType(.username)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "App Token")
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
HStack{
Spacer()
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
userSettings.onboarding = false
}
}
} label: {
Text("Submit")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.padding()
Spacer()
}
}
else if selectedLoginMethod == .v2 {
LoginLabel(text: "Server address")
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.done)
.padding(.bottom)
.onSubmit {
if userSettings.serverAddress == "" { return }
Task {
await sendLoginV2Request()
if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
}
Text("Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'.")
.font(.subheadline)
.padding(.bottom)
.foregroundStyle(.white)
Button {
Task {
await sendLoginV2Request()
if let loginRequest = loginRequest {
await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
} label: {
Text("Open in browser")
.foregroundColor(.white)
.font(.headline)
}
HStack{
Spacer()
Button {
// fetch login v2 response
Task {
guard let res = await fetchLoginV2Response() else {
alertMessage = "Login failed. Please login via the browser and try again."
showAlert = true
return
}
print("Login successfull for user \(res.loginName)!")
userSettings.username = res.loginName
userSettings.token = res.appPassword
userSettings.onboarding = false
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.disabled(loginRequest == nil ? true : false)
.padding()
Spacer()
}
}
Spacer()
}
.onSubmit {
switch focusedField {
case .server:
focusedField = .username
case .username:
focusedField = .token
default:
print("Attempting to log in ...")
}
}
.fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
}
func sendLoginV2Request() async {
let hostPath = "https://\(userSettings.serverAddress)"
let headerFields: [HeaderField] = [
//HeaderField.ocsRequest(value: true),
//HeaderField.accept(value: .JSON)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: .LOGINV2REQ,
headerFields: headerFields
)
do {
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: hostPath,
authString: nil
)
guard let data = data else { return }
print("Data: \(data)")
let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data)
self.loginRequest = loginReq
} catch {
print("Could not establish communication with the server.")
}
}
func fetchLoginV2Response() async -> LoginV2Response? {
guard let loginRequest = loginRequest else { return nil }
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: .NONE,
headerFields: headerFields,
body: "token=\(loginRequest.poll.token)".data(using: .utf8),
authenticate: false
)
var (data, error): (Data?, Error?) = (nil, nil)
do {
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: loginRequest.poll.endpoint,
authString: nil
)
} catch {
print("Error: ", error)
}
guard let data = data else { return nil }
if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) {
return loginRes
}
print("Could not decode.")
return nil
}
func loginCheck(nextcloudLogin: Bool) async -> Bool {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!"
showAlert = true
return false
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
alertMessage = "Please enter a user name and app token!"
showAlert = true
return false
}
let headerFields = [
HeaderField.ocsRequest(value: true),
]
let request = RequestWrapper.customRequest(
method: .GET,
path: .CATEGORIES,
headerFields: headerFields,
authenticate: true
)
var (data, error): (Data?, Error?) = (nil, nil)
do {
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
let authString = loginData.base64EncodedString()
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/",
authString: authString
)
} catch {
print("Error: ", error)
}
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
if let testRequest: [Category] = JSONDecoder.safeDecode(data) {
print("validationResponse: \(testRequest)")
return true
}
alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true
return false
}
}
struct LoginLabel: View {
let text: String
var body: some View {
Text(text)
.foregroundColor(.white)
.font(.headline)
.padding(.vertical, 5)
}
}
struct LoginTextField: View {
var example: String
@Binding var text: String
var body: some View {
TextField(example, text: $text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.foregroundColor(.white)
.accentColor(.white)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
}

View File

@@ -51,11 +51,11 @@ struct RecipeCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 17))
.padding(.horizontal) .padding(.horizontal)
.task { .task {
recipeThumb = await viewModel.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) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
} }
.refreshable { .refreshable {
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferServer)
} }
} }
} }

View File

@@ -106,13 +106,13 @@ struct RecipeDetailView: View {
} }
} }
.task { .task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferLocal)//loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) 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) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
} }
.refreshable { .refreshable {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer)
} }
} }
} }

View File

@@ -45,10 +45,12 @@ struct RecipeEditView: View {
} }
Spacer() Spacer()
Button() { Button() {
Task {
if viewModel.uploadNew { if viewModel.uploadNew {
viewModel.uploadNewRecipe() await viewModel.uploadNewRecipe()
} else { } else {
viewModel.uploadEditedRecipe() await viewModel.uploadEditedRecipe()
}
} }
} label: { } label: {
Text("Upload") Text("Upload")
@@ -141,7 +143,7 @@ struct RecipeEditView: View {
} }
} }
.task { .task {
viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords() viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords(fetchMode: .preferServer)
} }
.onAppear { .onAppear {
viewModel.prepareView() viewModel.prepareView()
@@ -150,13 +152,17 @@ struct RecipeEditView: View {
ForEach(viewModel.alertType.alertButtons) { buttonType in ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK { if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) { Button(AlertButton.OK.rawValue, role: .cancel) {
viewModel.alertAction() Task {
await viewModel.alertAction()
}
} }
} else if buttonType == .CANCEL { } else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { } Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE { } else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) { Button(AlertButton.DELETE.rawValue, role: .destructive) {
viewModel.alertAction() Task {
await viewModel.alertAction()
}
} }
} }
} }