Merge branch 'download-rework' into api-update
This commit is contained in:
@@ -38,6 +38,11 @@
|
|||||||
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 */; };
|
A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */; };
|
||||||
A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */; };
|
A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */; };
|
||||||
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
||||||
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||||
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||||
|
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 */; };
|
||||||
@@ -96,6 +101,11 @@
|
|||||||
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>"; };
|
A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
||||||
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.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 +205,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 */,
|
||||||
@@ -245,6 +257,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>";
|
||||||
@@ -273,6 +286,22 @@
|
|||||||
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */,
|
A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */,
|
||||||
);
|
);
|
||||||
path = Onboarding;
|
path = Onboarding;
|
||||||
|
A79AA8E72B062DB6007D25F2 /* CookbookApi */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
||||||
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
|
||||||
|
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
|
||||||
|
);
|
||||||
|
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,15 +448,19 @@
|
|||||||
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 */,
|
A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */,
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
@@ -435,6 +468,7 @@
|
|||||||
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 */,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,11 +61,30 @@ 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 ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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" : {
|
||||||
@@ -865,6 +874,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Error" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Error." : {
|
"Error." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1877,6 +1889,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Success" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Support" : {
|
"Support" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2100,6 +2115,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Unable to complete action." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Unable to connect to server." : {
|
"Unable to connect to server." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// NextcloudApi.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 16.11.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class NextcloudApi {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,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 +109,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 +212,7 @@ struct RecipeSearchView: View {
|
|||||||
.navigationTitle("Search recipe")
|
.navigationTitle("Search recipe")
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
allRecipes = await viewModel.getAllRecipes()
|
allRecipes = await viewModel.getRecipes()//.getAllRecipes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user