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