Better file caching and update management

This commit is contained in:
Vicnet
2023-12-14 14:11:56 +01:00
parent 899dc20e55
commit a3fc891d0a
23 changed files with 592 additions and 483 deletions

View File

@@ -26,13 +26,13 @@
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; }; A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.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 */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; }; A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; };
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; }; A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; }; A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.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 */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; }; A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; }; A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; }; A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
@@ -207,7 +206,6 @@
children = ( children = (
A79AA8EE2B063B33007D25F2 /* NextcloudApi */, A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */, A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A703226C2ABAF90D00D7C4ED /* APIController.swift */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
A70171B02AB211DF00064C43 /* CustomError.swift */, A70171B02AB211DF00064C43 /* CustomError.swift */,
@@ -258,6 +256,7 @@
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */,
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */, A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */,
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -272,7 +271,6 @@
A781E75F2AF8228100452F6F /* RecipeImport */ = { A781E75F2AF8228100452F6F /* RecipeImport */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A74D33BF2AF82CB500D06555 /* TestScraper.playground */,
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */, A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
); );
path = RecipeImport; path = RecipeImport;
@@ -455,6 +453,7 @@
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.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 */,
@@ -471,7 +470,6 @@
A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */,
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.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 */,

View File

@@ -5,9 +5,9 @@
"color-space" : "display-p3", "color-space" : "display-p3",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0.871", "blue" : "0.948",
"green" : "0.871", "green" : "0.948",
"red" : "0.871" "red" : "0.948"
} }
}, },
"idiom" : "universal" "idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "display-p3", "color-space" : "display-p3",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0.871", "blue" : "0.948",
"green" : "0.871", "green" : "0.948",
"red" : "0.871" "red" : "0.948"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@@ -25,6 +25,13 @@ struct Recipe: Codable {
let imageUrl: String let imageUrl: String
let imagePlaceholderUrl: String let imagePlaceholderUrl: String
let recipe_id: Int 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 { extension Recipe: Identifiable, Hashable {
@@ -91,16 +98,6 @@ struct RecipeDetail: Codable {
recipeInstructions = [] recipeInstructions = []
nutrition = [:] nutrition = [:]
} }
func getKeywordsArray() -> [String] {
return keywords.components(separatedBy: ",")
}
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
if !self.keywords.isEmpty {
self.keywords = keywordsArray.joined(separator: ",")
}
}
} }
extension RecipeDetail { extension RecipeDetail {
@@ -124,7 +121,18 @@ extension RecipeDetail {
recipeInstructions: [], recipeInstructions: [],
nutrition: [:] nutrition: [:]
) )
} }
func getKeywordsArray() -> [String] {
if keywords == "" { return [] }
return keywords.components(separatedBy: ",")
}
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
if !keywordsArray.isEmpty {
self.keywords = keywordsArray.joined(separator: ",")
}
}
func getNutritionList() -> [String]? { func getNutritionList() -> [String]? {
var stringList: [String] = [] var stringList: [String] = []
@@ -146,8 +154,8 @@ extension RecipeDetail {
struct RecipeImage { struct RecipeImage {
enum RecipeImageSize { enum RecipeImageSize: String {
case THUMB, FULL case THUMB="thumb", FULL="full"
} }
var imageExists: Bool = true var imageExists: Bool = true
var thumb: UIImage? var thumb: UIImage?

View File

@@ -10,6 +10,9 @@ import Foundation
import Combine import Combine
class UserSettings: ObservableObject { class UserSettings: ObservableObject {
static let shared = UserSettings()
@Published var username: String { @Published var username: String {
didSet { didSet {
UserDefaults.standard.set(username, forKey: "username") UserDefaults.standard.set(username, forKey: "username")
@@ -52,9 +55,27 @@ class UserSettings: ObservableObject {
} }
} }
@Published var downloadRecipes: Bool { @Published var storeRecipes: Bool {
didSet { 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.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.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 authString == "" {
if token != "" && username != "" { if token != "" && username != "" {

View 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
}
}
}

View File

@@ -66,6 +66,9 @@
} }
} }
} }
},
"(%lld)" : {
}, },
"%@" : { "%@" : {
"localizations" : { "localizations" : {
@@ -292,9 +295,6 @@
} }
} }
} }
},
"Action completed." : {
}, },
"Action delayed" : { "Action delayed" : {
@@ -496,6 +496,9 @@
} }
} }
} }
},
"Configure what is stored on your device." : {
}, },
"Connected to server." : { "Connected to server." : {
"localizations" : { "localizations" : {
@@ -631,6 +634,9 @@
}, },
"Could not establish a connection to the server. The action will be retried upon reconnection." : { "Could not establish a connection to the server. The action will be retried upon reconnection." : {
},
"Created: %@" : {
}, },
"Delete" : { "Delete" : {
"localizations" : { "localizations" : {
@@ -765,6 +771,7 @@
} }
}, },
"Download all recipes" : { "Download all recipes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -787,6 +794,7 @@
} }
}, },
"Download recipes" : { "Download recipes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -807,6 +815,9 @@
} }
} }
} }
},
"Downloads" : {
}, },
"Duplicate recipe." : { "Duplicate recipe." : {
"localizations" : { "localizations" : {
@@ -1216,6 +1227,12 @@
} }
} }
} }
},
"Last modified: %@" : {
},
"Last updated: %@" : {
}, },
"Log out" : { "Log out" : {
"localizations" : { "localizations" : {
@@ -1486,6 +1503,9 @@
} }
} }
} }
},
"Offline recipes" : {
}, },
"Ok" : { "Ok" : {
"localizations" : { "localizations" : {
@@ -1707,6 +1727,9 @@
} }
} }
} }
},
"Refresh all" : {
}, },
"Same as Device" : { "Same as Device" : {
"localizations" : { "localizations" : {
@@ -1864,6 +1887,12 @@
}, },
"Show help" : { "Show help" : {
},
"Store recipe images locally" : {
},
"Store recipe thumbnails locally" : {
}, },
"Submit" : { "Submit" : {
"localizations" : { "localizations" : {
@@ -1886,9 +1915,6 @@
} }
} }
} }
},
"Success" : {
}, },
"Support" : { "Support" : {
"localizations" : { "localizations" : {

View File

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

View File

@@ -77,10 +77,14 @@ struct ApiRequest {
do { do {
(data, response) = try await URLSession.shared.data(for: request) (data, response) = try await URLSession.shared.data(for: request)
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") 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 { if let data = data {
print(data, String(data: data, encoding: .utf8)) print(data, String(data: data, encoding: .utf8))
} }
return (data, nil) return (data!, nil)
} catch { } catch {
let error = decodeURLResponse(response: response as? HTTPURLResponse) let error = decodeURLResponse(response: response as? HTTPURLResponse)
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
@@ -92,6 +96,7 @@ struct ApiRequest {
guard let response = response else { guard let response = response else {
return NetworkError.unknownError return NetworkError.unknownError
} }
print("Status code: ", response.statusCode)
switch response.statusCode { switch response.statusCode {
case 200...299: return (nil) case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError) case 300...399: return (NetworkError.redirectionError)

View File

@@ -132,7 +132,7 @@ protocol CookbookApi {
static func getTags( static func getTags(
from serverAdress: String, from serverAdress: String,
auth: String auth: String
) async -> ([String]?, NetworkError?) ) async -> ([RecipeKeyword]?, NetworkError?)
/// Get all recipes tagged with the specified keyword. /// Get all recipes tagged with the specified keyword.
/// - Parameters: /// - Parameters:

View File

@@ -53,7 +53,7 @@ class CookbookApiV1: CookbookApi {
path: "/api/v1/recipes", path: "/api/v1/recipes",
method: .POST, method: .POST,
authString: auth, 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 body: recipeData
) )
@@ -95,7 +95,7 @@ class CookbookApiV1: CookbookApi {
path: "/api/v1/recipes/\(recipe.id)", path: "/api/v1/recipes/\(recipe.id)",
method: .PUT, method: .PUT,
authString: auth, 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 body: recipeData
) )
@@ -170,7 +170,7 @@ class CookbookApiV1: CookbookApi {
return nil 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( let request = ApiRequest(
serverAdress: serverAdress, serverAdress: serverAdress,
path: "/api/v1/keywords", path: "/api/v1/keywords",

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeScraper { class RecipeScraper {
func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportError?) { func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportAlert?) {
var contents: String? = nil var contents: String? = nil
if let url = URL(string: url) { if let url = URL(string: url) {
do { do {

View File

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

View File

@@ -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>

View File

@@ -11,15 +11,13 @@ import UIKit
@MainActor class MainViewModel: ObservableObject { @MainActor class MainViewModel: ObservableObject {
@AppStorage("authString") var authString = "" @ObservedObject var userSettings = UserSettings.shared
@AppStorage("username") var userName = ""
@AppStorage("token") var appToken = ""
@AppStorage("serverAddress") var serverAdress = ""
@Published var categories: [Category] = [] @Published var categories: [Category] = []
@Published var recipes: [String: [Recipe]] = [:] @Published var recipes: [String: [Recipe]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:]
var recipeImages: [Int: [String: UIImage]] = [:]
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
private var requestQueue: [RequestWrapper] = [] private var requestQueue: [RequestWrapper] = []
private let api: CookbookApi.Type private let api: CookbookApi.Type
@@ -30,10 +28,10 @@ import UIKit
self.api = api self.api = api
self.dataStore = DataStore() self.dataStore = DataStore()
if authString == "" { if userSettings.authString == "" {
let loginString = "\(userName):\(appToken)" let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)! let loginData = loginString.data(using: String.Encoding.utf8)!
authString = loginData.base64EncodedString() userSettings.authString = loginData.base64EncodedString()
} }
} }
@@ -51,21 +49,26 @@ import UIKit
*/ */
func getCategories() async { func getCategories() async {
let (categories, _) = await api.getCategories( let (categories, _) = await api.getCategories(
from: serverAdress, from: userSettings.serverAddress,
auth: authString auth: userSettings.authString
) )
if let categories = categories { if let categories = categories {
print("Successfully loaded categories")
self.categories = categories self.categories = categories
print(categories)
await saveLocal(categories, path: "categories.data") await saveLocal(categories, path: "categories.data")
} else { } else {
// If there's no server connection, try loading categories from local storage // 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") { if let categories: [Category] = await loadLocal(path: "categories.data") {
self.categories = categories self.categories = categories
print("Success!")
} else {
print("Failure!")
} }
} }
} }
/** /**
Fetches recipes for a specified category from either the server or local storage. Fetches recipes for a specified category from either the server or local storage.
@@ -88,14 +91,18 @@ import UIKit
return false return false
} }
func getServer() async -> Bool { func getServer(store: Bool = false) async -> Bool {
let (recipes, _) = await api.getCategory( let (recipes, _) = await api.getCategory(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
named: name named: categoryString
) )
if let recipes = recipes { if let recipes = recipes {
self.recipes[name] = recipes self.recipes[name] = recipes
if store {
await saveLocal(recipes, path: "category_\(categoryString).data")
}
//userSettings.lastUpdate = Date()
return true return true
} }
return false return false
@@ -105,9 +112,9 @@ import UIKit
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if await getLocal() { return } if await getLocal() { return }
if await getServer() { return } if await getServer(store: true) { return }
case .preferServer: case .preferServer:
if await getServer() { return } if await getServer(store: true) { return }
if await getLocal() { return } if await getLocal() { return }
case .onlyLocal: case .onlyLocal:
if await getLocal() { return } 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. Asynchronously retrieves all recipes either from the server or the locally cached data.
@@ -129,8 +156,8 @@ import UIKit
*/ */
func getRecipes() async -> [Recipe] { func getRecipes() async -> [Recipe] {
let (recipes, error) = await api.getRecipes( let (recipes, error) = await api.getRecipes(
from: serverAdress, from: userSettings.serverAddress,
auth: authString auth: userSettings.authString
) )
if let recipes = recipes { if let recipes = recipes {
return recipes return recipes
@@ -162,18 +189,16 @@ import UIKit
```swift ```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123) 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? { func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
return recipe
}
return nil return nil
} }
func getServer() async -> RecipeDetail? { func getServer() async -> RecipeDetail? {
let (recipe, error) = await api.getRecipe( let (recipe, error) = await api.getRecipe(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
id: id id: id
) )
if let recipe = recipe { if let recipe = recipe {
@@ -196,7 +221,7 @@ import UIKit
case .onlyServer: case .onlyServer:
if let recipe = await getServer() { return recipe } if let recipe = await getServer() { return recipe }
} }
return .error return nil
} }
@@ -213,26 +238,36 @@ import UIKit
```swift ```swift
await mainViewModel.downloadAllRecipes() await mainViewModel.downloadAllRecipes()
*/ */
/*
func downloadAllRecipes() async { func downloadAllRecipes() async {
for category in categories { for category in categories {
await getCategory(named: category.name, fetchMode: .onlyServer) 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 recipeDetail = await getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer) await downloadRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages)
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")
} }
} }
} }
*/
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 /// Check if recipeDetail is stored locally, either in cache or on disk
@@ -240,9 +275,7 @@ import UIKit
/// - recipeId: The id of a recipe. /// - recipeId: The id of a recipe.
/// - Returns: True if the recipeDetail is stored, otherwise false /// - Returns: True if the recipeDetail is stored, otherwise false
func recipeDetailExists(recipeId: Int) -> Bool { func recipeDetailExists(recipeId: Int) -> Bool {
if recipeDetails[recipeId] != nil { if (dataStore.recipeDetailExists(recipeId: recipeId)) {
return true
} else if (dataStore.recipeDetailExists(recipeId: recipeId)) {
return true return true
} }
return false return false
@@ -271,8 +304,8 @@ import UIKit
func getServer() async -> UIImage? { func getServer() async -> UIImage? {
let (image, _) = await api.getImage( let (image, _) = await api.getImage(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
id: id, id: id,
size: size size: size
) )
@@ -282,16 +315,51 @@ import UIKit
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if let image = await getLocal() { return image } if let image = imageFromCache(id: id, size: size) {
if let image = await getServer() { return image } 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: case .preferServer:
if let image = await getServer() { return image } if let image = await getServer() {
if let image = await getLocal() { return image } 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: case .onlyLocal:
if let image = await getLocal() { return image } if let image = imageFromCache(id: id, size: size) {
return image
}
if !imageUpdateNeeded(id: id, size: size) { return nil }
if let image = await getLocal() {
imageToCache(id: id, size: size, image: image)
return image
}
case .onlyServer: case .onlyServer:
if let image = await getServer() { return image } if let image = imageFromCache(id: id, size: size) {
return image
}
if let image = await getServer() {
imageToCache(id: id, size: size, image: image)
return image
}
} }
imagesNeedUpdate[id] = [size.rawValue: false]
return nil return nil
} }
@@ -306,15 +374,15 @@ import UIKit
```swift ```swift
let keywords = await mainViewModel.getKeywords() let keywords = await mainViewModel.getKeywords()
*/ */
func getKeywords(fetchMode: FetchMode) async -> [String] { func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
func getLocal() async -> [String]? { func getLocal() async -> [RecipeKeyword]? {
return await loadLocal(path: "keywords.data") return await loadLocal(path: "keywords.data")
} }
func getServer() async -> [String]? { func getServer() async -> [RecipeKeyword]? {
let (tags, _) = await api.getTags( let (tags, _) = await api.getTags(
from: serverAdress, from: userSettings.serverAddress,
auth: authString auth: userSettings.authString
) )
return tags return tags
} }
@@ -322,9 +390,15 @@ import UIKit
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if let keywords = await getLocal() { return keywords } 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: 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 } if let keywords = await getLocal() { return keywords }
case .onlyLocal: case .onlyLocal:
if let keywords = await getLocal() { return keywords } if let keywords = await getLocal() { return keywords }
@@ -340,6 +414,8 @@ import UIKit
self.recipes = [:] self.recipes = [:]
self.recipeDetails = [:] self.recipeDetails = [:]
self.requestQueue = [] self.requestQueue = []
self.recipeImages = [:]
self.imagesNeedUpdate = [:]
} }
} }
@@ -358,10 +434,10 @@ import UIKit
```swift ```swift
let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts") 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( let (error) = await api.deleteRecipe(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
id: id id: id
) )
@@ -376,7 +452,7 @@ import UIKit
}) })
recipeDetails.removeValue(forKey: id) recipeDetails.removeValue(forKey: id)
} }
return .REQUEST_SUCCESS return nil
} }
/** /**
@@ -392,8 +468,8 @@ import UIKit
*/ */
func checkServerConnection() async -> Bool { func checkServerConnection() async -> Bool {
let (categories, _) = await api.getCategories( let (categories, _) = await api.getCategories(
from: serverAdress, from: userSettings.serverAddress,
auth: authString auth: userSettings.authString
) )
if let categories = categories { if let categories = categories {
self.categories = categories self.categories = categories
@@ -418,25 +494,25 @@ import UIKit
```swift ```swift
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true) 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 var error: NetworkError? = nil
if createNew { if createNew {
error = await api.createRecipe( error = await api.createRecipe(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
recipe: recipeDetail recipe: recipeDetail
) )
} else { } else {
error = await api.updateRecipe( error = await api.updateRecipe(
from: serverAdress, from: userSettings.serverAddress,
auth: authString, auth: userSettings.authString,
recipe: recipeDetail recipe: recipeDetail
) )
} }
if let error = error { if let error = error {
return .REQUEST_DROPPED return .REQUEST_DROPPED
} }
return .REQUEST_SUCCESS return nil
} }
} }
@@ -472,6 +548,72 @@ extension MainViewModel {
} }
return nil 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)
}
}

View File

@@ -10,7 +10,6 @@ import SwiftUI
@MainActor class RecipeEditViewModel: ObservableObject { @MainActor class RecipeEditViewModel: ObservableObject {
@ObservedObject var mainViewModel: MainViewModel @ObservedObject var mainViewModel: MainViewModel
@Published var isPresented: Binding<Bool>
@Published var recipe: RecipeDetail = RecipeDetail() @Published var recipe: RecipeDetail = RecipeDetail()
@Published var prepDuration: DurationComponents = DurationComponents() @Published var prepDuration: DurationComponents = DurationComponents()
@@ -19,29 +18,25 @@ import SwiftUI
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var keywords: [String] = [] @Published var keywords: [String] = []
@Published var keywordSuggestions: [String] = [] @Published var keywordSuggestions: [RecipeKeyword] = []
@Published var showImportSection: Bool = false @Published var showImportSection: Bool = false
@Published var importURL: String = "" @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 uploadNew: Bool = true
var waitingForUpload: Bool = false var waitingForUpload: Bool = false
init(mainViewModel: MainViewModel, isPresented: Binding<Bool>, uploadNew: Bool) { init(mainViewModel: MainViewModel, uploadNew: Bool) {
self.mainViewModel = mainViewModel self.mainViewModel = mainViewModel
self.isPresented = isPresented
self.uploadNew = uploadNew self.uploadNew = uploadNew
} }
init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, isPresented: Binding<Bool>, uploadNew: Bool) { init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, uploadNew: Bool) {
self.mainViewModel = mainViewModel self.mainViewModel = mainViewModel
self.recipe = recipeDetail self.recipe = recipeDetail
self.isPresented = isPresented
self.uploadNew = uploadNew self.uploadNew = uploadNew
} }
@@ -53,13 +48,10 @@ import SwiftUI
self.recipe.setKeywordsFromArray(keywords) self.recipe.setKeywordsFromArray(keywords)
} }
func recipeValid() -> Bool { func recipeValid() -> RecipeAlert? {
// 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 return RecipeAlert.NO_TITLE
alertAction = {return .REQUEST_DROPPED}
presentAlert = true
return false
} }
// Check if the recipe has a unique name // Check if the recipe has a unique name
for recipeList in mainViewModel.recipes.values { for recipeList in mainViewModel.recipes.values {
@@ -71,52 +63,41 @@ import SwiftUI
.replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: " ", with: "")
.lowercased() .lowercased()
{ {
alertType = RecipeCreationError.DUPLICATE return RecipeAlert.DUPLICATE
alertAction = {return .REQUEST_DROPPED}
presentAlert = true
return false
} }
} }
} }
return true return nil
} }
func uploadNewRecipe() async -> RequestAlert { func uploadNewRecipe() async -> UserAlert? {
print("Uploading new recipe.") print("Uploading new recipe.")
waitingForUpload = true waitingForUpload = true
createRecipe() createRecipe()
guard recipeValid() else { return .REQUEST_DROPPED } if let recipeValidationError = recipeValid() {
return recipeValidationError
}
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true) return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
} }
func uploadEditedRecipe() async -> RequestAlert { func uploadEditedRecipe() async -> UserAlert? {
waitingForUpload = true waitingForUpload = true
print("Uploading changed recipe.") 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() createRecipe()
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false) return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
} }
func deleteRecipe() async -> RequestAlert { func deleteRecipe() async -> RequestAlert? {
guard let id = Int(recipe.id) else { guard let id = Int(recipe.id) else {
return .REQUEST_DROPPED return .REQUEST_DROPPED
} }
return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory) 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() { func prepareView() {
if let prepTime = recipe.prepTime { if let prepTime = recipe.prepTime {
prepDuration.fromPTString(prepTime) prepDuration.fromPTString(prepTime)
@@ -130,22 +111,19 @@ import SwiftUI
self.keywords = recipe.getKeywordsArray() self.keywords = recipe.getKeywordsArray()
} }
func importRecipe() { func importRecipe() async -> UserAlert? {
Task { do {
do { let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL)
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) if let scrapedRecipe = scrapedRecipe {
if let scrapedRecipe = scrapedRecipe { self.recipe = scrapedRecipe
self.recipe = scrapedRecipe prepareView()
prepareView()
}
if let error = error {
self.alertType = error
self.alertAction = {return .REQUEST_DROPPED}
self.presentAlert = true
}
} catch {
print("Error")
} }
if let error = error {
return error
}
} catch {
print("Error")
} }
return nil
} }
} }

View File

@@ -25,7 +25,7 @@ enum AlertButton: LocalizedStringKey, Identifiable {
enum RecipeCreationError: UserAlert { enum RecipeAlert: UserAlert {
case NO_TITLE, case NO_TITLE,
DUPLICATE, DUPLICATE,
@@ -84,7 +84,7 @@ enum RecipeCreationError: UserAlert {
} }
enum RecipeImportError: UserAlert { enum RecipeImportAlert: UserAlert {
case BAD_URL, case BAD_URL,
CHECK_CONNECTION, CHECK_CONNECTION,
WEBSITE_NOT_SUPPORTED WEBSITE_NOT_SUPPORTED
@@ -113,14 +113,12 @@ enum RecipeImportError: UserAlert {
enum RequestAlert: UserAlert { enum RequestAlert: UserAlert {
case REQUEST_DELAYED, case REQUEST_DELAYED,
REQUEST_DROPPED, REQUEST_DROPPED
REQUEST_SUCCESS
var localizedDescription: LocalizedStringKey { var localizedDescription: LocalizedStringKey {
switch self { switch self {
case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection." 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_DROPPED: return "Unable to complete action."
case .REQUEST_SUCCESS: return "Action completed."
} }
} }
@@ -128,7 +126,6 @@ enum RequestAlert: UserAlert {
switch self { switch self {
case .REQUEST_DELAYED: return "Action delayed" case .REQUEST_DELAYED: return "Action delayed"
case .REQUEST_DROPPED: return "Error" case .REQUEST_DROPPED: return "Error"
case .REQUEST_SUCCESS: return "Success"
} }
} }

View File

@@ -22,6 +22,7 @@ struct CategoryDetailView: View {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe) RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -30,41 +31,29 @@ struct CategoryDetailView: View {
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: viewModel, recipe: recipe) RecipeDetailView(viewModel: viewModel, recipe: recipe)
} }
.navigationTitle(categoryName == "*" ? "Other" : categoryName) .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button {
Menu { print("Add new recipe")
Button { showEditView = true
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: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "plus.circle.fill")
} }
} }
} }
.searchable(text: $searchText, prompt: "Search recipes") .searchable(text: $searchText, prompt: "Search recipes")
.task { .task {
await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal) await viewModel.getCategory(
named: categoryName,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
)
} }
.refreshable { .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()) 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")
}
}
}
}
} }

View File

@@ -12,7 +12,7 @@ import SwiftUI
struct KeywordPickerView: View { struct KeywordPickerView: View {
@State var title: String @State var title: String
@State var searchSuggestions: [String] @State var searchSuggestions: [RecipeKeyword]
@Binding var selection: [String] @Binding var selection: [String]
@State var searchText: String = "" @State var searchText: String = ""
@@ -35,17 +35,18 @@ struct KeywordPickerView: View {
s == keyword ? true : false s == keyword ? true : false
}) })
searchSuggestions.removeAll(where: { s in searchSuggestions.removeAll(where: { s in
s == keyword ? true : false s.name == keyword ? true : false
}) })
} else { } else {
selection.append(keyword) selection.append(keyword)
} }
} }
} }
ForEach(suggestionsFiltered(), id: \.self) { suggestion in ForEach(suggestionsFiltered(), id: \.name) { suggestion in
KeywordItemView( KeywordItemView(
keyword: suggestion, keyword: suggestion.name,
isSelected: selection.contains(suggestion) count: suggestion.recipe_count,
isSelected: selection.contains(suggestion.name)
) { keyword in ) { keyword in
if selection.contains(keyword) { if selection.contains(keyword) {
selection.removeAll(where: { s in selection.removeAll(where: { s in
@@ -84,14 +85,17 @@ struct KeywordPickerView: View {
} }
} }
.navigationTitle(title) .navigationTitle(title)
.padding(5)
} }
func suggestionsFiltered() -> [String] { func suggestionsFiltered() -> [RecipeKeyword] {
guard searchText != "" else { return searchSuggestions } guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in 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 { struct KeywordItemView: View {
var keyword: String var keyword: String
var count: Int? = nil
var isSelected: Bool var isSelected: Bool
var tapped: (String) -> () var tapped: (String) -> ()
@@ -110,6 +115,9 @@ struct KeywordItemView: View {
Text(keyword) Text(keyword)
.lineLimit(2) .lineLimit(2)
Spacer() Spacer()
if let count = count {
Text("(\(count))")
}
} }
.padding() .padding()
.background( .background(

View File

@@ -10,13 +10,14 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@StateObject var userSettings: UserSettings = UserSettings() @StateObject var userSettings: UserSettings = UserSettings.shared
@State private var selectedCategory: Category? = nil @State private var selectedCategory: Category? = nil
@State private var showEditView: Bool = false @State private var showEditView: Bool = false
@State private var showSearchView: Bool = false @State private var showSearchView: Bool = false
@State private var showSettingsView: Bool = false @State private var showSettingsView: Bool = false
@State private var serverConnection: Bool = false @State private var serverConnection: Bool = false
@State private var showLoadingIndicator: Bool = false
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
@@ -43,7 +44,7 @@ struct MainView: View {
NavigationLink(value: category) { NavigationLink(value: category) {
HStack(alignment: .center) { HStack(alignment: .center) {
Image(systemName: "book.closed.fill") 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)) .font(.system(size: 20, weight: .light, design: .serif))
.italic() .italic()
}.padding(7) }.padding(7)
@@ -53,7 +54,7 @@ struct MainView: View {
} }
.navigationTitle("Cookbooks") .navigationTitle("Cookbooks")
.navigationDestination(isPresented: $showSettingsView) { .navigationDestination(isPresented: $showSettingsView) {
SettingsView(userSettings: userSettings, viewModel: viewModel) SettingsView(viewModel: viewModel)
} }
.navigationDestination(isPresented: $showSearchView) { .navigationDestination(isPresented: $showSearchView) {
RecipeSearchView(viewModel: viewModel) RecipeSearchView(viewModel: viewModel)
@@ -63,7 +64,8 @@ struct MainView: View {
viewModel: viewModel, viewModel: viewModel,
showEditView: $showEditView, showEditView: $showEditView,
showSettingsView: $showSettingsView, showSettingsView: $showSettingsView,
serverConnection: $serverConnection serverConnection: $serverConnection,
showLoadingIndicator: $showLoadingIndicator
) )
} }
} detail: { } detail: {
@@ -80,17 +82,24 @@ struct MainView: View {
} }
.tint(.nextcloudBlue) .tint(.nextcloudBlue)
.sheet(isPresented: $showEditView) { .sheet(isPresented: $showEditView) {
RecipeEditView(viewModel: RecipeEditView(
RecipeEditViewModel( viewModel:
mainViewModel: viewModel, RecipeEditViewModel(
isPresented: $showEditView, mainViewModel: viewModel,
uploadNew: true uploadNew: true
) ),
isPresented: $showEditView
) )
} }
.task { .task {
showLoadingIndicator = true
self.serverConnection = await viewModel.checkServerConnection() 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 // 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
@@ -102,10 +111,11 @@ struct MainView: View {
self.selectedCategory = cat self.selectedCategory = cat
} }
} }
showLoadingIndicator = false
} }
.refreshable { .refreshable {
self.serverConnection = await viewModel.checkServerConnection() 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 showEditView: Bool
@Binding var showSettingsView: Bool @Binding var showSettingsView: Bool
@Binding var serverConnection: Bool @Binding var serverConnection: Bool
@Binding var showLoadingIndicator: Bool
@State private var presentPopover: Bool = false @State private var presentPopover: Bool = false
var body: some ToolbarContent { var body: some ToolbarContent {
// Top left menu toolbar item // Top left menu toolbar item
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Menu { Menu {
Button {
print("Downloading all recipes ...")
Task {
await viewModel.downloadAllRecipes()
}
} label: {
HStack {
Text("Download all recipes")
Image(systemName: "icloud.and.arrow.down")
}
}
Button { Button {
self.showSettingsView = true self.showSettingsView = true
} label: { } label: {
Text("Settings") Text("Settings")
Image(systemName: "gearshape") 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: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
} }
@@ -154,16 +167,24 @@ struct MainView: View {
print("Check server connection") print("Check server connection")
presentPopover = true presentPopover = true
} label: { } label: {
if serverConnection { if showLoadingIndicator {
ProgressView()
} else if serverConnection {
Image(systemName: "checkmark.icloud") Image(systemName: "checkmark.icloud")
} else { } else {
Image(systemName: "xmark.icloud") Image(systemName: "xmark.icloud")
} }
}.popover(isPresented: $presentPopover) { }.popover(isPresented: $presentPopover) {
Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server.")) VStack(alignment: .leading) {
.bold() Text(serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
.padding() .bold()
.presentationCompactAdaptation(.popover)
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 ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe) RecipeCardView(viewModel: viewModel, recipe: recipe)
.shadow(radius: 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -208,7 +230,7 @@ struct RecipeSearchView: View {
.navigationTitle("Search recipe") .navigationTitle("Search recipe")
} }
.task { .task {
allRecipes = await viewModel.getRecipes()//.getAllRecipes() allRecipes = await viewModel.getRecipes()
} }
} }

View File

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

View File

@@ -75,6 +75,14 @@ struct RecipeDetailView: View {
RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover) RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover)
RecipeKeywordSection(recipeDetail: recipeDetail, presentKeywordPopover: $presentKeywordPopover) 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) }.padding(.horizontal, 5)
} }
@@ -95,24 +103,42 @@ struct RecipeDetailView: View {
} }
.sheet(isPresented: $presentEditView) { .sheet(isPresented: $presentEditView) {
if let recipeDetail = recipeDetail { if let recipeDetail = recipeDetail {
RecipeEditView(viewModel: RecipeEditView(
RecipeEditViewModel( viewModel:
mainViewModel: viewModel, RecipeEditViewModel(
recipeDetail: recipeDetail, mainViewModel: viewModel,
isPresented: $presentEditView, recipeDetail: recipeDetail,
uploadNew: false uploadNew: false
) ),
isPresented: $presentEditView
) )
} }
} }
.task { .task {
recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferLocal)//loadRecipeDetail(recipeId: recipe.recipe_id) recipeDetail = await viewModel.getRecipe(
recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferLocal)//.loadImage(recipeId: recipe.recipe_id, thumb: false) id: recipe.recipe_id,
self.isDownloaded = viewModel.recipeDetailExists(recipeId: 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 { .refreshable {
recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer) recipeDetail = await viewModel.getRecipe(
recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer) 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
)
} }
} }
} }

View File

@@ -13,13 +13,18 @@ import PhotosUI
struct RecipeEditView: View { struct RecipeEditView: View {
@ObservedObject var viewModel: RecipeEditViewModel @ObservedObject var viewModel: RecipeEditViewModel
@Binding var isPresented: Bool
@State var presentAlert = false
@State var alertType: UserAlert = RecipeAlert.GENERIC
@State var alertAction: @MainActor () async -> () = { }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack { VStack {
HStack { HStack {
Button() { Button() {
viewModel.isPresented.wrappedValue = false isPresented = false
} label: { } label: {
Text("Cancel") Text("Cancel")
.bold() .bold()
@@ -28,9 +33,17 @@ struct RecipeEditView: View {
Menu { Menu {
Button { Button {
print("Delete recipe.") print("Delete recipe.")
viewModel.alertType = RecipeCreationError.CONFIRM_DELETE alertType = RecipeAlert.CONFIRM_DELETE
viewModel.alertAction = viewModel.deleteRecipe alertAction = {
viewModel.presentAlert = true if let res = await viewModel.deleteRecipe() {
alertType = res
alertAction = { }
presentAlert = true
} else {
self.dismissEditView()
}
}
presentAlert = true
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundStyle(.red) .foregroundStyle(.red)
@@ -47,9 +60,19 @@ struct RecipeEditView: View {
Button() { Button() {
Task { Task {
if viewModel.uploadNew { if viewModel.uploadNew {
await viewModel.uploadNewRecipe() if let res = await viewModel.uploadNewRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
} else { } else {
await viewModel.uploadEditedRecipe() if let res = await viewModel.uploadEditedRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
} }
} }
} label: { } label: {
@@ -58,7 +81,7 @@ struct RecipeEditView: View {
} }
}.padding() }.padding()
HStack { 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) .font(.title)
.bold() .bold()
.padding() .padding()
@@ -69,7 +92,16 @@ struct RecipeEditView: View {
Section { Section {
TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importURL) TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importURL)
Button { Button {
viewModel.importRecipe() Task {
if let res = await viewModel.importRecipe() {
alertType = RecipeAlert.CUSTOM(
title: res.localizedTitle,
description: res.localizedDescription
)
alertAction = { }
presentAlert = true
}
}
} label: { } label: {
Text(LocalizedStringKey("Import")) Text(LocalizedStringKey("Import"))
} }
@@ -148,12 +180,12 @@ struct RecipeEditView: View {
.onAppear { .onAppear {
viewModel.prepareView() viewModel.prepareView()
} }
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { .alert(alertType.localizedTitle, isPresented: $presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in ForEach(alertType.alertButtons) { buttonType in
if buttonType == .OK { if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) { Button(AlertButton.OK.rawValue, role: .cancel) {
Task { Task {
await viewModel.alertAction() await alertAction()
} }
} }
} else if buttonType == .CANCEL { } else if buttonType == .CANCEL {
@@ -161,15 +193,24 @@ struct RecipeEditView: View {
} else if buttonType == .DELETE { } else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) { Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task { Task {
await viewModel.alertAction() await alertAction()
} }
} }
} }
} }
} message: { } 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
}
} }

View File

@@ -11,8 +11,8 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var userSettings: UserSettings
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings = UserSettings.shared
@State fileprivate var alertType: SettingsAlert = .NONE @State fileprivate var alertType: SettingsAlert = .NONE
@State var showAlert: Bool = false @State var showAlert: Bool = false
@@ -20,9 +20,6 @@ struct SettingsView: View {
var body: some View { var body: some View {
Form { Form {
Section { Section {
/*Toggle(isOn: $userSettings.downloadRecipes) {
Text("Always download new recipes")
}*/
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) { Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
Text("None").tag("None") Text("None").tag("None")
ForEach(viewModel.categories, id: \.name) { category in 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.") 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 { Section {
Picker("Language", selection: $userSettings.language) { Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in ForEach(SupportedLanguage.allValues, id: \.self) { lang in
@@ -102,6 +115,7 @@ struct SettingsView: View {
userSettings.serverAddress = "" userSettings.serverAddress = ""
userSettings.username = "" userSettings.username = ""
userSettings.token = "" userSettings.token = ""
userSettings.authString = ""
viewModel.deleteAllData() viewModel.deleteAllData()
userSettings.onboarding = true userSettings.onboarding = true
} }