WIP - Complete App refactoring

This commit is contained in:
VincentMeilinger
2025-05-26 15:52:24 +02:00
parent 29fd3c668b
commit 5acf3b9c4f
49 changed files with 1996 additions and 543 deletions

View File

@@ -6,3 +6,40 @@
//
import Foundation
@Observable
class AccountManager {
var accounts: [any Account] = []
var authTokens: [String: String] = [:]
/// Save account as JSON.
func save(account: any Account, authToken: String?) async throws {
account.saveTokenToKeychain(authToken!)
let data = try JSONEncoder().encode(account)
let accountDir = account.accountType.rawValue + "/" + account.id.uuidString + "/account.json"
await DataStore.shared.save(data: data, toPath: accountDir)
}
/// Load accounts from JSON files.
func loadAccounts() async throws {
// Read data from file or user defaults
for accountType in AccountType.allCases {
// List all account UUIDs under the /accountType directory
let accountUUIDs = DataStore.shared.listAllFolders(dir: accountType.rawValue + "/")
// Decode each account and fetch the authToken
for accountUUID in accountUUIDs {
do {
guard let account = try await DataStore.shared.loadDynamic(fromPath: accountType.rawValue + "/" + accountUUID + "/account.json", type: accountType.accountType) else {
continue
}
authTokens[accountUUID] = (account as! any Account).getTokenFromKeychain() ?? ""
self.accounts.append(account as! (any Account))
} catch {
continue
}
}
}
}
}

View File

@@ -6,3 +6,44 @@
//
import Foundation
import SwiftUI
import KeychainSwift
enum AccountType: String, Codable, CaseIterable {
case cookbook = "cookbook"
case local = "local"
var accountType: any Decodable.Type {
switch self {
case .cookbook: return CookbookAccount.self
case .local: return LocalAccount.self
}
}
}
protocol Account: Codable, Identifiable {
/// A unique identifier for this account
var id: UUID { get }
/// A name for the account that can be displayed in the UI
var displayName: String { get }
/// For differentiating account types when decoding
var accountType: AccountType { get }
/// Base endpoint URL
var baseURL: URL { get }
/// Account username
var username: String { get }
/// For storing/retrieving tokens from Keychain
func saveTokenToKeychain(_ token: String)
func getTokenFromKeychain() -> String?
}

View File

@@ -1,5 +1,5 @@
//
// CookbookV1Account.swift
// CookbookAccount.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 24.01.25.
@@ -10,9 +10,10 @@ import KeychainSwift
struct CookbookAccount: Account {
let accountType: AccountType = .cookbook
let id: UUID
var displayName: String = "Nextcloud Cookbook Account"
let accountType: AccountType = .cookbook
let baseURL: URL
let username: String

View File

@@ -6,3 +6,23 @@
//
import Foundation
struct LocalAccount: Account {
let id: UUID
var displayName: String = "Local Account"
var accountType: AccountType = .local
let baseURL: URL = URL(filePath: "")!
let username: String = ""
/// Keychain convenience
func saveTokenToKeychain(_ token: String) {
return
}
func getTokenFromKeychain() -> String? {
return nil
}
}

View File

@@ -9,11 +9,11 @@ import Foundation
import SwiftUI
import UIKit
/*
@MainActor class AppState: ObservableObject {
@Published var categories: [Category] = []
@Published var recipes: [String: [Recipe]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:]
@Published var recipes: [String: [CookbookApiRecipeV1]] = [:]
@Published var recipeDetails: [Int: CookbookApiRecipeDetailV1] = [:]
@Published var timers: [String: RecipeTimer] = [:]
var recipeImages: [Int: [String: UIImage]] = [:]
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
@@ -86,7 +86,7 @@ import UIKit
func getCategory(named name: String, fetchMode: FetchMode) async {
print("getCategory(\(name), fetchMode: \(fetchMode))")
func getLocal() async -> Bool {
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
if let recipes: [CookbookApiRecipeV1] = await loadLocal(path: "category_\(categoryString).data") {
self.recipes[name] = recipes
return true
}
@@ -159,7 +159,7 @@ import UIKit
```swift
let recipes = await mainViewModel.getRecipes()
*/
func getRecipes() async -> [Recipe] {
func getRecipes() async -> [CookbookApiRecipeV1] {
let (recipes, error) = await cookbookApi.getRecipes(
auth: UserSettings.shared.authString
)
@@ -168,7 +168,7 @@ import UIKit
} else if let error = error {
print(error)
}
var allRecipes: [Recipe] = []
var allRecipes: [CookbookApiRecipeV1] = []
for category in categories {
if let recipeArray = self.recipes[category.name] {
allRecipes.append(contentsOf: recipeArray)
@@ -193,13 +193,13 @@ import UIKit
```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123)
*/
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> CookbookApiRecipeDetailV1? {
func getLocal() async -> CookbookApiRecipeDetailV1? {
if let recipe: CookbookApiRecipeDetailV1 = await loadLocal(path: "recipe\(id).data") { return recipe }
return nil
}
func getServer() async -> RecipeDetail? {
func getServer() async -> CookbookApiRecipeDetailV1? {
let (recipe, error) = await cookbookApi.getRecipe(
auth: UserSettings.shared.authString,
id: id
@@ -483,7 +483,7 @@ import UIKit
```swift
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
*/
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? {
func uploadRecipe(recipeDetail: CookbookApiRecipeDetailV1, createNew: Bool) async -> RequestAlert? {
var error: NetworkError? = nil
if createNew {
error = await cookbookApi.createRecipe(
@@ -502,7 +502,7 @@ import UIKit
return nil
}
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
func importRecipe(url: String) async -> (CookbookApiRecipeDetailV1?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await cookbookApi.importRecipe(
auth: UserSettings.shared.authString,
@@ -633,3 +633,4 @@ extension AppState {
timers.removeValue(forKey: recipeId)
}
}
*/

View File

@@ -37,12 +37,21 @@ class DataStore {
guard let data = try? Data(contentsOf: fileURL) else {
return nil
}
let storedRecipes = try JSONDecoder().decode(D.self, from: data)
return storedRecipes
let decodedData = try JSONDecoder().decode(D.self, from: data)
return decodedData
}
return try await task.value
}
func loadDynamic(fromPath path: String, type: Decodable.Type) async throws -> Any? {
let fileURL = try Self.fileURL(appending: path)
guard let data = try? Data(contentsOf: fileURL) else {
return nil
}
let decoded = try JSONDecoder().decode(type, from: data)
return decoded
}
func save<D: Encodable>(data: D, toPath path: String) async {
let task = Task {
let data = try JSONEncoder().encode(data)
@@ -69,6 +78,27 @@ class DataStore {
return fileManager.fileExists(atPath: folderPath + filePath)
}
func listAllFolders(dir: String) -> [String] {
guard let baseURL = try? Self.fileURL() else {
print("Failed to retrieve documents directory.")
return []
}
let targetURL = baseURL.appendingPathComponent(dir)
do {
let contents = try fileManager.contentsOfDirectory(at: targetURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
let folders = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
return folders.map { $0.lastPathComponent }
} catch {
print("Error listing folders in \(dir): \(error)")
return []
}
}
func clearAll() -> Bool {
print("Attempting to delete all data ...")
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }

View File

@@ -7,18 +7,183 @@
import Foundation
import SwiftUI
import SwiftData
class ObservableRecipeDetail: ObservableObject {
// MARK: - Recipe Model
@Model
class RecipeImage {
enum RecipeImageSize {
case THUMB, FULL
}
var imageData: Data?
@Transient
var image: UIImage? {
guard let imageData else { return nil }
return UIImage(data: imageData)
}
init(imageData: Data? = nil) {
self.imageData = imageData
}
}
@Model
class RecipeThumbnail {
var thumbnailData: Data?
@Transient
var thumbnail: UIImage? {
guard let thumbnailData else { return nil }
return UIImage(data: thumbnailData)
}
init(thumbnailData: Data? = nil) {
self.thumbnailData = thumbnailData
}
}
@Model
class Recipe {
var id: String
var name: String
var keywords: [String]
@Attribute(.externalStorage) var image: RecipeImage?
var thumbnail: RecipeThumbnail?
var dateCreated: String? = nil
var dateModified: String? = nil
var prepTime: String
var cookTime: String
var totalTime: String
var recipeDescription: String
var url: String?
var yield: Int
var category: String
var tools: [String]
var ingredients: [String]
var instructions: [String]
var nutrition: [String:String]
// Additional functionality
@Transient
var ingredientMultiplier: Double = 1.0
init(
id: String,
name: String,
keywords: [String],
dateCreated: String? = nil,
dateModified: String? = nil,
prepTime: String,
cookTime: String,
totalTime: String,
recipeDescription: String,
url: String? = nil,
yield: Int,
category: String,
tools: [String],
ingredients: [String],
instructions: [String],
nutrition: [String : String],
ingredientMultiplier: Double
) {
self.id = id
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.recipeDescription = recipeDescription
self.url = url
self.yield = yield
self.category = category
self.tools = tools
self.ingredients = ingredients
self.instructions = instructions
self.nutrition = nutrition
self.ingredientMultiplier = ingredientMultiplier
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
keywords = try container.decode([String].self, forKey: .keywords)
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
prepTime = try container.decode(String.self, forKey: .prepTime)
cookTime = try container.decode(String.self, forKey: .cookTime)
totalTime = try container.decode(String.self, forKey: .totalTime)
recipeDescription = try container.decode(String.self, forKey: .recipeDescription)
url = try container.decodeIfPresent(String.self, forKey: .url)
yield = try container.decode(Int.self, forKey: .yield)
category = try container.decode(String.self, forKey: .category)
tools = try container.decode([String].self, forKey: .tools)
ingredients = try container.decode([String].self, forKey: .ingredients)
instructions = try container.decode([String].self, forKey: .instructions)
nutrition = try container.decode([String: String].self, forKey: .nutrition)
}
}
extension Recipe: Codable {
enum CodingKeys: String, CodingKey {
case id, name, keywords, dateCreated, dateModified, prepTime, cookTime, totalTime, recipeDescription, url, yield, category, tools, ingredients, instructions, nutrition
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encode(dateCreated, forKey: .dateCreated)
try container.encode(dateModified, forKey: .dateModified)
try container.encode(prepTime, forKey: .prepTime)
try container.encode(cookTime, forKey: .cookTime)
try container.encode(totalTime, forKey: .totalTime)
try container.encode(recipeDescription, forKey: .recipeDescription)
try container.encode(url, forKey: .url)
try container.encode(yield, forKey: .yield)
try container.encode(category, forKey: .category)
try container.encode(tools, forKey: .tools)
try container.encode(ingredients, forKey: .ingredients)
try container.encode(instructions, forKey: .instructions)
try container.encode(nutrition, forKey: .nutrition)
}
}
// MARK: - Recipe Stub
struct RecipeStub: Codable, Hashable, Identifiable {
let id: String
let name: String
let keywords: String?
let dateCreated: String?
let dateModified: String?
let thumbnailPath: String?
var storedLocally: Bool = false
var lastUpdated: String?
}
// MARK: - Recipe
/*
class Recipe: ObservableObject {
// Cookbook recipe detail fields
var id: String
@Published var name: String
@Published var keywords: [String]
@Published var imageUrl: String
@Published var imageUrl: String?
var dateCreated: String? = nil
var dateModified: String? = nil
@Published var prepTime: DurationComponents
@Published var cookTime: DurationComponents
@Published var totalTime: DurationComponents
@Published var description: String
@Published var url: String
@Published var url: String?
@Published var recipeYield: Int
@Published var recipeCategory: String
@Published var tool: [String]
@@ -51,7 +216,7 @@ class ObservableRecipeDetail: ObservableObject {
ingredientMultiplier = 1
}
init(_ recipeDetail: RecipeDetail) {
init(_ recipeDetail: CookbookApiRecipeDetailV1) {
id = recipeDetail.id
name = recipeDetail.name
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
@@ -71,8 +236,70 @@ class ObservableRecipeDetail: ObservableObject {
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
}
func toRecipeDetail() -> RecipeDetail {
return RecipeDetail(
init(
name: String,
keywords: [String],
dateCreated: String?,
dateModified: String?,
imageUrl: String?,
id: String,
prepTime: DurationComponents? = nil,
cookTime: DurationComponents? = nil,
totalTime: DurationComponents? = nil,
description: String,
url: String?,
recipeYield: Int,
recipeCategory: String,
tool: [String],
recipeIngredient: [String],
recipeInstructions: [String],
nutrition: [String:String]
) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime ?? DurationComponents()
self.cookTime = cookTime ?? DurationComponents()
self.totalTime = totalTime ?? DurationComponents()
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
ingredientMultiplier = Double(recipeYield == 0 ? 1 : recipeYield)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
_name = Published(initialValue: try container.decode(String.self, forKey: .name))
_keywords = Published(initialValue: try container.decode([String].self, forKey: .keywords))
_imageUrl = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .imageUrl))
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
_prepTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .prepTime))
_cookTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .cookTime))
_totalTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .totalTime))
_description = Published(initialValue: try container.decode(String.self, forKey: .description))
_url = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .url))
_recipeYield = Published(initialValue: try container.decode(Int.self, forKey: .recipeYield))
_recipeCategory = Published(initialValue: try container.decode(String.self, forKey: .recipeCategory))
_tool = Published(initialValue: try container.decode([String].self, forKey: .tool))
_recipeIngredient = Published(initialValue: try container.decode([String].self, forKey: .recipeIngredient))
_recipeInstructions = Published(initialValue: try container.decode([String].self, forKey: .recipeInstructions))
_nutrition = Published(initialValue: try container.decode([String: String].self, forKey: .nutrition))
_ingredientMultiplier = Published(initialValue: try container.decode(Double.self, forKey: .ingredientMultiplier))
}
func toRecipeDetail() -> CookbookApiRecipeDetailV1 {
return CookbookApiRecipeDetailV1(
name: self.name,
keywords: self.keywords.joined(separator: ","),
dateCreated: "",
@@ -98,14 +325,14 @@ class ObservableRecipeDetail: ObservableObject {
return AttributedString(ingredient)
}
// Match mixed fractions first
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
var matches = Recipe.matchPatternAndMultiply(
.mixedFraction,
in: ingredient,
multFactor: factor
)
// Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
Recipe.matchPatternAndMultiply(
.fraction,
in: ingredient,
multFactor: factor,
@@ -114,7 +341,7 @@ class ObservableRecipeDetail: ObservableObject {
)
// Match numbers at last, exclude all prior matches
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
Recipe.matchPatternAndMultiply(
.number,
in: ingredient,
multFactor: factor,
@@ -221,6 +448,34 @@ class ObservableRecipeDetail: ObservableObject {
}
}
extension Recipe: Codable {
enum CodingKeys: String, CodingKey {
case id, name, keywords, imageUrl, dateCreated, dateModified, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition, ingredientMultiplier
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encode(imageUrl, forKey: .imageUrl)
try container.encode(dateCreated, forKey: .dateCreated)
try container.encode(dateModified, forKey: .dateModified)
try container.encode(prepTime, forKey: .prepTime)
try container.encode(cookTime, forKey: .cookTime)
try container.encode(totalTime, forKey: .totalTime)
try container.encode(description, forKey: .description)
try container.encode(url, forKey: .url)
try container.encode(recipeYield, forKey: .recipeYield)
try container.encode(recipeCategory, forKey: .recipeCategory)
try container.encode(tool, forKey: .tool)
try container.encode(recipeIngredient, forKey: .recipeIngredient)
try container.encode(recipeInstructions, forKey: .recipeInstructions)
try container.encode(nutrition, forKey: .nutrition)
try container.encode(ingredientMultiplier, forKey: .ingredientMultiplier)
}
}
enum RegexPattern: String, CaseIterable, Identifiable {
case mixedFraction, fraction, number
@@ -249,3 +504,4 @@ enum RegexPattern: String, CaseIterable, Identifiable {
}
}
*/

View File

@@ -69,6 +69,7 @@
}
},
"(%lld)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -135,6 +136,7 @@
}
},
"%@: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -285,6 +287,7 @@
}
},
"%lld Serving(s)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -397,6 +400,7 @@
}
},
"A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -419,6 +423,7 @@
}
},
"About" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -441,6 +446,7 @@
}
},
"Acknowledgements" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -508,6 +514,7 @@
}
},
"Add cooking steps for fellow chefs to follow." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -530,6 +537,7 @@
}
},
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -552,6 +560,7 @@
}
},
"Add new recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -574,6 +583,7 @@
}
},
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -641,6 +651,7 @@
}
},
"App Token Login" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -753,6 +764,7 @@
}
},
"Category" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -821,6 +833,7 @@
}
},
"Choose" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -841,8 +854,12 @@
}
}
}
},
"Client error" : {
},
"Comma (e.g. 1,42)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -865,6 +882,7 @@
}
},
"Configure what is stored on your device." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -887,6 +905,7 @@
}
},
"Configure which sections in your recipes are expanded by default." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -909,6 +928,7 @@
}
},
"Connected to server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -953,6 +973,7 @@
}
},
"Cookbook Client" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -975,6 +996,7 @@
}
},
"Cookbooks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1041,6 +1063,7 @@
}
},
"Copy Link" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1085,6 +1108,7 @@
}
},
"Created: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1105,8 +1129,12 @@
}
}
}
},
"Data decoding failed." : {
},
"Decimal number format" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1127,6 +1155,9 @@
}
}
}
},
"Decoding Error" : {
},
"Delete" : {
"localizations" : {
@@ -1151,6 +1182,7 @@
}
},
"Delete local data" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1196,6 +1228,7 @@
}
},
"Delete Recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1240,6 +1273,7 @@
}
},
"Deleting local data will not affect the recipe data stored on your server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1284,6 +1318,7 @@
}
},
"Description" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1328,6 +1363,7 @@
}
},
"Downloads" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1395,6 +1431,7 @@
}
},
"e.g.: example.com" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1417,6 +1454,7 @@
}
},
"Edit" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1437,6 +1475,9 @@
}
}
}
},
"Encoding Error" : {
},
"Error" : {
"localizations" : {
@@ -1483,6 +1524,7 @@
}
},
"Expand information section" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1505,6 +1547,7 @@
}
},
"Expand keyword section" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1527,6 +1570,7 @@
}
},
"Expand nutrition section" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1595,6 +1639,7 @@
}
},
"Fraction" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1617,6 +1662,7 @@
}
},
"General" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1639,6 +1685,7 @@
}
},
"Get support" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1661,6 +1708,7 @@
}
},
"Grocery List" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1683,6 +1731,7 @@
}
},
"Hours" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1705,6 +1754,7 @@
}
},
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1727,6 +1777,7 @@
}
},
"If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1749,6 +1800,7 @@
}
},
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1771,6 +1823,7 @@
}
},
"If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1793,6 +1846,7 @@
}
},
"Import" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1815,6 +1869,7 @@
}
},
"Import Recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1837,6 +1892,7 @@
}
},
"Ingredient" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1859,6 +1915,7 @@
}
},
"Ingredients" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1927,6 +1984,7 @@
}
},
"Instruction" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1949,6 +2007,7 @@
}
},
"Instructions" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1969,8 +2028,15 @@
}
}
}
},
"Invalid data error." : {
},
"Invalid request" : {
},
"Keep screen awake when viewing recipes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1993,6 +2059,7 @@
}
},
"Keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2015,6 +2082,7 @@
}
},
"Language" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2037,6 +2105,7 @@
}
},
"Last modified: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2059,6 +2128,7 @@
}
},
"Last updated: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2081,6 +2151,7 @@
}
},
"List your tools here. 🍴" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2103,6 +2174,7 @@
}
},
"Log out" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2125,6 +2197,7 @@
}
},
"Login" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2169,6 +2242,7 @@
}
},
"Login Method" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2191,6 +2265,7 @@
}
},
"Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2213,6 +2288,7 @@
}
},
"Marked ingredients could not be adjusted!" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2235,6 +2311,7 @@
}
},
"Minutes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2323,8 +2400,12 @@
}
}
}
},
"Missing URL." : {
},
"Mixed fraction" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2347,6 +2428,7 @@
}
},
"More information" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2389,6 +2471,9 @@
}
}
}
},
"New" : {
},
"New recipe" : {
"extractionState" : "stale",
@@ -2436,6 +2521,7 @@
}
},
"Nextcloud Login" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2458,6 +2544,7 @@
}
},
"No keywords." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2480,6 +2567,7 @@
}
},
"No nutritional information." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2502,6 +2590,7 @@
}
},
"None" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2524,6 +2613,7 @@
}
},
"Number" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2546,6 +2636,7 @@
}
},
"Nutrition" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2568,6 +2659,7 @@
}
},
"Nutrition (%@)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2590,6 +2682,7 @@
}
},
"Offline recipes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2634,6 +2727,7 @@
}
},
"Other" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2654,6 +2748,12 @@
}
}
}
},
"Parameter encoding failed." : {
},
"Parameters are nil." : {
},
"Parsing error" : {
"localizations" : {
@@ -2678,6 +2778,7 @@
}
},
"Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2788,6 +2889,7 @@
}
},
"Point (e.g. 1.42)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2810,6 +2912,7 @@
}
},
"Preparation" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2900,6 +3003,7 @@
}
},
"Recipe Name" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2944,6 +3048,7 @@
}
},
"Recipes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2964,8 +3069,12 @@
}
}
}
},
"Redirection error" : {
},
"Refresh" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2988,6 +3097,7 @@
}
},
"Refresh all" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3055,6 +3165,7 @@
}
},
"Search" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3077,6 +3188,7 @@
}
},
"Search recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3099,6 +3211,7 @@
}
},
"Search recipes/keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3121,6 +3234,7 @@
}
},
"Select a default cookbook" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3143,6 +3257,7 @@
}
},
"Select Keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3165,6 +3280,7 @@
}
},
"Selected keywords:" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3185,6 +3301,9 @@
}
}
}
},
"Server error" : {
},
"Serving size" : {
"comment" : "Serving size",
@@ -3210,6 +3329,7 @@
}
},
"Servings" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3344,6 +3464,7 @@
}
},
"Share Recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3366,6 +3487,7 @@
}
},
"Show help" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3411,6 +3533,7 @@
}
},
"Start by adding your first ingredient! 🥬" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3433,6 +3556,7 @@
}
},
"Store recipe images locally" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3455,6 +3579,7 @@
}
},
"Store recipe thumbnails locally" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3477,6 +3602,7 @@
}
},
"Submit" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3544,6 +3670,7 @@
}
},
"Support" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3566,6 +3693,7 @@
}
},
"SwiftSoup" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3588,6 +3716,7 @@
}
},
"Thank you for downloading" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3610,6 +3739,7 @@
}
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3655,6 +3785,7 @@
}
},
"The selected cookbook will open on app launch by default." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3677,6 +3808,7 @@
}
},
"There are no recipes in this cookbook!" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3744,6 +3876,7 @@
}
},
"This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3766,6 +3899,7 @@
}
},
"This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3833,6 +3967,7 @@
}
},
"Tool" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3855,6 +3990,7 @@
}
},
"Tools" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3900,6 +4036,7 @@
}
},
"Total time" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3922,6 +4059,7 @@
}
},
"TPPDF" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3989,6 +4127,7 @@
}
},
"Unable to connect to server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4009,6 +4148,15 @@
}
}
}
},
"Unable to decode recipe data." : {
},
"Unable to encode recipe data." : {
},
"Unable to load recipe." : {
},
"Unable to load website content. Please check your internet connection." : {
"localizations" : {
@@ -4031,6 +4179,9 @@
}
}
}
},
"Unable to save recipe." : {
},
"Unable to upload your recipe. Please check your internet connection." : {
"localizations" : {
@@ -4053,6 +4204,9 @@
}
}
}
},
"Unknown error" : {
},
"Unsaturated fat content" : {
"comment" : "Unsaturated fat content",
@@ -4101,6 +4255,7 @@
}
},
"Upload Changes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4123,6 +4278,7 @@
}
},
"Upload Recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4145,6 +4301,7 @@
}
},
"URL (e.g. example.com/recipe)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4167,6 +4324,7 @@
}
},
"URL:" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4189,6 +4347,7 @@
}
},
"Username: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4211,6 +4370,7 @@
}
},
"Validate" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4233,6 +4393,7 @@
}
},
"Visit the GitHub page" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4255,6 +4416,7 @@
}
},
"You're all set for cooking 🍓" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4277,6 +4439,7 @@
}
},
"Your grocery list is stored locally and therefore not synchronized across your devices." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {

View File

@@ -7,10 +7,10 @@
import Foundation
import SwiftUI
/*
@MainActor class RecipeEditViewModel: ObservableObject {
@ObservedObject var mainViewModel: AppState
@Published var recipe: RecipeDetail = RecipeDetail()
@Published var recipe: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1()
@Published var prepDuration: DurationComponents = DurationComponents()
@Published var cookDuration: DurationComponents = DurationComponents()
@@ -34,7 +34,7 @@ import SwiftUI
self.uploadNew = uploadNew
}
init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) {
init(mainViewModel: AppState, recipeDetail: CookbookApiRecipeDetailV1, uploadNew: Bool) {
self.mainViewModel = mainViewModel
self.recipe = recipeDetail
self.uploadNew = uploadNew
@@ -135,3 +135,4 @@ import SwiftUI
}
}
*/

View File

@@ -32,7 +32,7 @@ protocol CookbookApi {
static func importRecipe(
auth: String,
data: Data
) async -> (RecipeDetail?, NetworkError?)
) async -> (Recipe?, NetworkError?)
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
@@ -42,7 +42,7 @@ protocol CookbookApi {
/// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil.
static func getImage(
auth: String,
id: Int,
id: String,
size: RecipeImage.RecipeImageSize
) async -> (UIImage?, NetworkError?)
@@ -52,7 +52,7 @@ protocol CookbookApi {
/// - Returns: A list of all recipes.
static func getRecipes(
auth: String
) async -> ([Recipe]?, NetworkError?)
) async -> ([RecipeStub]?, NetworkError?)
/// Create a new recipe.
/// - Parameters:
@@ -60,7 +60,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func createRecipe(
auth: String,
recipe: RecipeDetail
recipe: Recipe
) async -> (NetworkError?)
/// Get the recipe with the specified id.
@@ -69,8 +69,9 @@ protocol CookbookApi {
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
static func getRecipe(
auth: String, id: Int
) async -> (RecipeDetail?, NetworkError?)
auth: String,
id: String
) async -> (Recipe?, NetworkError?)
/// Update an existing recipe with new entries.
/// - Parameters:
@@ -79,7 +80,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func updateRecipe(
auth: String,
recipe: RecipeDetail
recipe: Recipe
) async -> (NetworkError?)
/// Delete the recipe with the specified id.
@@ -89,7 +90,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func deleteRecipe(
auth: String,
id: Int
id: String
) async -> (NetworkError?)
/// Get all categories.
@@ -108,7 +109,7 @@ protocol CookbookApi {
static func getCategory(
auth: String,
named categoryName: String
) async -> ([Recipe]?, NetworkError?)
) async -> ([RecipeStub]?, NetworkError?)
/// Rename an existing category.
/// - Parameters:
@@ -138,7 +139,7 @@ protocol CookbookApi {
static func getRecipesTagged(
auth: String,
keyword: String
) async -> ([Recipe]?, NetworkError?)
) async -> ([RecipeStub]?, NetworkError?)
/// Get the servers api version.
/// - Parameters:
@@ -176,3 +177,4 @@ protocol CookbookApi {

View File

@@ -12,7 +12,7 @@ import UIKit
class CookbookApiV1: CookbookApi {
static let basePath: String = "/index.php/apps/cookbook/api/v1"
static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) {
static func importRecipe(auth: String, data: Data) async -> (Recipe?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/import",
method: .POST,
@@ -22,10 +22,12 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), error)
}
static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
static func getImage(auth: String, id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
let imageSize = (size == .FULL ? "full" : "thumb")
let request = ApiRequest(
path: basePath + "/recipes/\(id)/image?size=\(imageSize)",
@@ -39,7 +41,7 @@ class CookbookApiV1: CookbookApi {
return (UIImage(data: data), error)
}
static func getRecipes(auth: String) async -> ([Recipe]?, NetworkError?) {
static func getRecipes(auth: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/recipes",
method: .GET,
@@ -50,10 +52,12 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
return (JSONDecoder.safeDecode(data), nil)
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
static func createRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let recipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
@@ -81,7 +85,8 @@ class CookbookApiV1: CookbookApi {
return nil
}
static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
static func getRecipe(auth: String, id: String) async -> (Recipe?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .GET,
@@ -91,10 +96,13 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), nil)
}
static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
@@ -121,7 +129,8 @@ class CookbookApiV1: CookbookApi {
return nil
}
static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) {
static func deleteRecipe(auth: String, id: String) async -> (NetworkError?) {
guard let id = Int(id) else {return .unknownError}
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .DELETE,
@@ -147,7 +156,7 @@ class CookbookApiV1: CookbookApi {
return (JSONDecoder.safeDecode(data), nil)
}
static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) {
static func getCategory(auth: String, named categoryName: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/category/\(categoryName)",
method: .GET,
@@ -157,7 +166,8 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
@@ -186,7 +196,7 @@ class CookbookApiV1: CookbookApi {
return (JSONDecoder.safeDecode(data), nil)
}
static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) {
static func getRecipesTagged(auth: String, keyword: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/tags/\(keyword)",
method: .GET,
@@ -196,7 +206,8 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func getApiVersion(auth: String) async -> (NetworkError?) {
@@ -215,3 +226,4 @@ class CookbookApiV1: CookbookApi {
return .none
}
}

View File

@@ -1,5 +1,5 @@
//
// Models.swift
// CookbookLoginModels.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
@@ -9,10 +9,7 @@ import Foundation
import SwiftUI
// MARK: - Login flow
// MARK: - Login Models
struct LoginV2Request: Codable {
let poll: LoginV2Poll

View File

@@ -8,8 +8,18 @@
import Foundation
import SwiftUI
struct Category: Codable, Identifiable, Hashable {
var id: String { name }
let name: String
let recipe_count: Int
private enum CodingKeys: String, CodingKey {
case name, recipe_count
}
}
struct CookbookApiRecipeV1: Codable {
struct CookbookApiRecipeV1: CookbookApiRecipe, Codable, Identifiable, Hashable {
var id: String { name + String(recipe_id) }
let name: String
let keywords: String?
let dateCreated: String?
@@ -24,15 +34,22 @@ struct CookbookApiRecipeV1: Codable {
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
}
func toRecipeStub() -> RecipeStub {
return RecipeStub(
id: String(recipe_id),
name: name,
keywords: keywords,
dateCreated: dateCreated,
dateModified: dateModified,
thumbnailPath: nil
)
}
}
extension CookbookApiRecipeV1: Identifiable, Hashable {
var id: String { name }
}
struct CookbookApiRecipeDetailV1: Codable {
struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
var name: String
var keywords: String
var dateCreated: String?
@@ -51,7 +68,7 @@ struct CookbookApiRecipeDetailV1: Codable {
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
init(name: String, keywords: String, dateCreated: String?, dateModified: String?, imageUrl: String?, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String?, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
@@ -117,6 +134,47 @@ struct CookbookApiRecipeDetailV1: Codable {
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
}
func toRecipe() -> Recipe {
return Recipe(
id: self.id,
name: self.name,
keywords: keywords.components(separatedBy: ","),
dateCreated: self.dateCreated,
dateModified: self.dateModified,
prepTime: self.prepTime ?? "",
cookTime: self.cookTime ?? "",
totalTime: self.totalTime ?? "",
recipeDescription: self.description,
url: self.url,
yield: self.recipeYield,
category: self.recipeCategory,
tools: self.tool,
ingredients: self.recipeIngredient,
instructions: self.recipeInstructions,
nutrition: self.nutrition,
ingredientMultiplier: 1.0
)
}
static func fromRecipe(_ recipe: Recipe) -> any CookbookApiRecipeDetail {
return CookbookApiRecipeDetailV1(
name: recipe.name,
keywords: recipe.keywords.joined(separator: ","),
dateCreated: recipe.dateCreated,
dateModified: recipe.dateModified,
imageUrl: "",
id: recipe.id,
description: recipe.recipeDescription,
url: recipe.url,
recipeYield: recipe.yield,
recipeCategory: recipe.category,
tool: recipe.tools,
recipeIngredient: recipe.ingredients,
recipeInstructions: recipe.instructions,
nutrition: recipe.nutrition
)
}
}
@@ -155,7 +213,7 @@ extension CookbookApiRecipeDetailV1 {
}
}
/*
struct RecipeImage {
enum RecipeImageSize: String {
case THUMB="thumb", FULL="full"
@@ -164,7 +222,7 @@ struct RecipeImage {
var thumb: UIImage?
var full: UIImage?
}
*/
struct RecipeKeyword: Codable {
let name: String
@@ -244,3 +302,4 @@ enum Nutrition: CaseIterable {
}
}
}

View File

@@ -6,3 +6,13 @@
//
import Foundation
protocol CookbookApiRecipe {
func toRecipeStub() -> RecipeStub
}
protocol CookbookApiRecipeDetail: Codable {
func toRecipe() -> Recipe
static func fromRecipe(_ recipe: Recipe) -> CookbookApiRecipeDetail
}

View File

@@ -6,18 +6,53 @@
//
import Foundation
import SwiftUI
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case decodingFailed = "Data decoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
public enum NetworkError: UserAlert {
case missingUrl
case parametersNil
case encodingFailed
case decodingFailed
case redirectionError
case clientError
case serverError
case invalidRequest
case unknownError
case dataError
var localizedTitle: LocalizedStringKey {
switch self {
case .missingUrl:
"Missing URL."
case .parametersNil:
"Parameters are nil."
case .encodingFailed:
"Parameter encoding failed."
case .decodingFailed:
"Data decoding failed."
case .redirectionError:
"Redirection error"
case .clientError:
"Client error"
case .serverError:
"Server error"
case .invalidRequest:
"Invalid request"
case .unknownError:
"Unknown error"
case .dataError:
"Invalid data error."
}
}
var localizedDescription: LocalizedStringKey {
return "" // TODO: Add description
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -6,8 +6,7 @@
//
import SwiftUI
import SwiftUI
import SwiftData
@main
struct Nextcloud_Cookbook_iOS_ClientApp: App {
@@ -18,9 +17,11 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
WindowGroup {
ZStack {
if onboarding {
OnboardingView()
//OnboardingView()
EmptyView()
} else {
MainView()
.modelContainer(for: Recipe.self)
}
}
.transition(.slide)

View File

@@ -1,8 +0,0 @@
//
// Account.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 24.01.25.
//
import Foundation

View File

@@ -1,5 +1,5 @@
//
// AccountState.swift
// CookbookState.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.24.
@@ -7,13 +7,10 @@
import Foundation
import SwiftUI
/*
@Observable
class CookbookState {
let id = UUID()
let account: Account? = nil
/// Caches recipe categories.
var categories: [Category] = []
@@ -33,212 +30,193 @@ class CookbookState {
var keywords: [RecipeKeyword] = []
/// Read and write interfaces.
var readLocal: ReadInterface
var writeLocal: WriteInterface
var readRemote: [ReadInterface]?
var writeRemote: [WriteInterface]?
var localOnly: Bool = false
var localReadInterface: ReadInterface
var localWriteInterface: WriteInterface
var remoteReadInterface: ReadInterface?
var remoteWriteInterface: WriteInterface?
/// UI state variables
var selectedCategory: Category? = nil
var selectedRecipe: RecipeStub? = nil
var navigationPath: NavigationPath = NavigationPath()
var selectedRecipeStub: RecipeStub? = nil
var showSettings: Bool = false
var showGroceries: Bool = false
/// Grocery List
var groceryList = GroceryList()
init(
readLocal: ReadInterface,
writeLocal: WriteInterface,
readRemote: [ReadInterface] = [],
writeRemote: [WriteInterface] = []
localReadInterface: ReadInterface,
localWriteInterface: WriteInterface,
remoteReadInterface: ReadInterface? = nil,
remoteWriteInterface: WriteInterface? = nil
) {
self.readLocal = readLocal
self.writeLocal = writeLocal
self.readRemote = readRemote
self.writeRemote = writeRemote
self.localReadInterface = localReadInterface
self.localWriteInterface = localWriteInterface
self.remoteReadInterface = remoteReadInterface
self.remoteWriteInterface = remoteWriteInterface
}
init() {
let accountLoader = AccountLoader()
rI, wI = accountLoader.load
}
}
extension CookbookState {
func removeRecipe(_ id: String) {
for key in recipeStubs.keys {
recipeStubs[key]?.removeAll(where: { $0.id == id })
}
recipes.removeValue(forKey: id)
thumbnails.removeValue(forKey: id)
images.removeValue(forKey: id)
}
func imgToCache(_ image: UIImage, id: String, size: RecipeImage.RecipeImageSize) {
if size == .THUMB {
thumbnails[id] = image
} else {
images[id] = image
}
}
func imgFromCache(id: String, size: RecipeImage.RecipeImageSize) -> UIImage? {
if size == .THUMB {
return thumbnails[id]
} else {
return images[id]
}
}
}
extension CookbookState: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
if let image = imgFromCache(id: id, size: size) {
return image
}
if !localOnly, let readRemote {
if let image = await readRemote.getImage(id: id, size: size) {
return image
}
}
if let image = await readLocal.getImage(id: id, size: size) {
return image
}
return nil
}
func getRecipeStubs() async -> [RecipeStub]? {
if !localOnly, let readRemote {
if let stubs = await readRemote.getRecipeStubs() {
return stubs
}
}
if categories.isEmpty {
self.categories = await readLocal.getCategories() ?? []
}
for category in self.categories {
self.recipeStubs[category.name] = await readLocal.getRecipeStubsForCategory(named: category.name)
}
return self.recipeStubs.flatMap({_, val in val})
}
func getRecipe(id: String) async -> Recipe? {
if let recipe = self.recipes[id] {
return recipe
}
if !localOnly, let readRemote {
if let recipe = await readRemote.getRecipe(id: id) {
return recipe
}
}
return await readLocal.getRecipe(id: id)
}
func getCategories() async -> [Category]? {
if !localOnly, let readRemote {
if let categories = await readRemote.getCategories() {
func loadCategories(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return categories
return
}
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
} else {
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
guard let remoteReadInterface else { return }
if let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return
}
}
if self.categories.isEmpty, let categories = await readLocal.getCategories() {
self.categories = categories
return categories
}
return self.categories
}
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
if let stubs = self.recipeStubs[categoryName] {
return stubs
}
if !localOnly, let readRemote {
if let stubs = await readRemote.getRecipeStubsForCategory(named: categoryName) {
self.recipeStubs[categoryName] = stubs
func loadRecipeStubs(category: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
} else {
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
guard let remoteReadInterface else { return }
if let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
}
if let stubs = await readLocal.getRecipeStubsForCategory(named: categoryName) {
self.recipeStubs[categoryName] = stubs
return stubs
}
return nil
}
func getTags() async -> [RecipeKeyword]? {
if !keywords.isEmpty {
return keywords
}
if !localOnly, let readRemote {
if let tags = await readRemote.getTags() {
self.keywords = tags
func loadKeywords(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
} else {
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
guard let remoteReadInterface else { return }
if let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
}
return await readLocal.getTags()
}
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
if !localOnly, let readRemote {
if let stubs = await readRemote.getRecipesTagged(keyword: keyword) {
return stubs
func loadRecipe(id: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
} else {
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
guard let remoteReadInterface else { return }
if let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
}
return await getRecipeStubs()?.filter({ recipe in
recipe.keywords?.contains(keyword.lowercased()) ?? false
})
}
}
extension CookbookState: WriteInterface {
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
let _ = await writeLocal.postImage(id: id, image: image, size: size)
let _ = await writeRemote?.postImage(id: id, image: image, size: size)
return nil
}
func postRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
let _ = await writeLocal.postRecipe(recipe: recipe)
let _ = await writeRemote?.postRecipe(recipe: recipe)
return nil
}
func updateRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
let _ = await writeLocal.updateRecipe(recipe: recipe)
let _ = await writeRemote?.updateRecipe(recipe: recipe)
return nil
}
func deleteRecipe(id: String) async -> ((any UserAlert)?) {
let _ = await writeLocal.deleteRecipe(id: id)
let _ = await writeRemote?.deleteRecipe(id: id)
return nil
}
func renameCategory(named categoryName: String, newName: String) async -> ((any UserAlert)?) {
let _ = await writeLocal.renameCategory(named: categoryName, newName: newName)
let _ = await writeRemote?.renameCategory(named: categoryName, newName: newName)
return nil
}
}
extension AccountState: Hashable, Identifiable {
static func == (lhs: AccountState, rhs: AccountState) -> Bool {
lhs.id == rhs.id
class AccountLoader {
func initInterfaces() async -> [ReadInterface & WriteInterface] {
let accounts = await self.loadAccounts("accounts.data")
if accounts.isEmpty && UserSettings.shared.serverAddress != "" {
print("Creating new Account from legacy Cookbook client account.")
let auth = Authentication(
baseUrl: UserSettings.shared.serverAddress,
user: UserSettings.shared.username,
token: UserSettings.shared.authString
)
let authKey = "legacyNextcloud"
let legacyAccount = Account(
id: UUID(),
name: "Nextcloud",
type: .nextcloud,
apiVersion: "1.0",
authKey: authKey
)
await saveAccounts([legacyAccount], "accounts.data")
legacyAccount.storeAuth(auth)
let interface = NextcloudDataInterface(auth: auth, version: legacyAccount.apiVersion)
return [interface]
} else {
print("Recovering existing accounts.")
var interfaces: [ReadInterface & WriteInterface] = []
for account in accounts {
if let interface: CookbookInterface = account.getInterface() {
interfaces.append(interface)
}
}
return interfaces
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
func loadAccounts(_ path: String) async -> [Account] {
do {
return try await DataStore.shared.load(fromPath: path) ?? []
} catch (let error) {
print(error)
return []
}
}
func saveAccounts(_ accounts: [Account], _ path: String) async {
await DataStore.shared.save(data: accounts, toPath: path)
}
}
*/

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
import KeychainSwift
/*
protocol CookbookInterface {
/// A unique id of the interface. Used to associate recipes to their respective accounts.
var id: String { get }
@@ -24,12 +24,12 @@ protocol ReadInterface {
func getImage(
id: String,
size: RecipeImage.RecipeImageSize
) async -> (UIImage?, UserAlert?)
) async -> UIImage?
/// Get all recipe stubs.
/// - Returns: A list of all recipes.
func getRecipeStubs(
) async -> ([RecipeStub]?, UserAlert?)
) async -> [RecipeStub]?
/// Get the recipe with the specified id.
/// - Parameters:
@@ -37,12 +37,12 @@ protocol ReadInterface {
/// - Returns: The recipe if it exists. A UserAlert if the request fails.
func getRecipe(
id: String
) async -> (Recipe?, UserAlert?)
) async -> Recipe?
/// Get all categories.
/// - Returns: A list of categories. A UserAlert if the request fails.
func getCategories(
) async -> ([Category]?, UserAlert?)
) async -> [Category]?
/// Get all recipes of a specified category.
/// - Parameters:
@@ -50,12 +50,12 @@ protocol ReadInterface {
/// - Returns: A list of recipes. A UserAlert if the request fails.
func getRecipeStubsForCategory(
named categoryName: String
) async -> ([RecipeStub]?, UserAlert?)
) async -> [RecipeStub]?
/// Get all keywords/tags.
/// - Returns: A list of tag strings. A UserAlert if the request fails.
func getTags(
) async -> ([RecipeKeyword]?, UserAlert?)
) async -> [RecipeKeyword]?
/// Get all recipes tagged with the specified keyword.
/// - Parameters:
@@ -63,7 +63,7 @@ protocol ReadInterface {
/// - Returns: A list of recipes tagged with the specified keyword. A UserAlert if the request fails.
func getRecipesTagged(
keyword: String
) async -> ([RecipeStub]?, UserAlert?)
) async -> [RecipeStub]?
}
protocol WriteInterface {
@@ -111,3 +111,4 @@ protocol WriteInterface {
newName: String
) async -> (UserAlert?)
}
*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
class LocalDataInterface: CookbookInterface {
var id: String
@@ -49,49 +49,36 @@ class LocalDataInterface: CookbookInterface {
// MARK: - Local Read Interface
extension LocalDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, (any UserAlert)?) {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
guard let data: String = await load(path: .image(id: id, size: size)) else {
return (nil, PersistenceAlert.LOAD_FAILED)
return nil
}
guard let dataDecoded = Data(base64Encoded: data) else { return (nil, PersistenceAlert.DECODING_FAILED) }
if let image = UIImage(data: dataDecoded) {
return (image, nil)
}
return (nil, nil)
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
return UIImage(data: dataDecoded)
}
func getRecipeStubs() async -> ([RecipeStub]?, (any UserAlert)?) {
return (nil, PersistenceAlert.LOAD_FAILED)
func getRecipeStubs() async -> [RecipeStub]? {
return nil
}
func getRecipe(id: String) async -> (Recipe?, (any UserAlert)?) {
if let recipe: Recipe? = await load(path: LocalDataPath.recipe(id: id)) {
return (recipe, nil)
}
return (nil, nil)
func getRecipe(id: String) async -> Recipe? {
return await load(path: LocalDataPath.recipe(id: id))
}
func getCategories() async -> ([Category]?, (any UserAlert)?) {
return (await load(path: LocalDataPath.categories), nil)
func getCategories() async -> [Category]? {
return await load(path: LocalDataPath.categories)
}
func getRecipeStubsForCategory(named categoryName: String) async -> ([RecipeStub]?, (any UserAlert)?) {
if let stubs: [RecipeStub] = await load(path: .recipeStubs(category: categoryName)) {
return (stubs, nil)
}
return (nil, PersistenceAlert.LOAD_FAILED)
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await load(path: .recipeStubs(category: categoryName))
}
func getTags() async -> ([RecipeKeyword]?, (any UserAlert)?) {
if let keywords: [RecipeKeyword] = await load(path: .keywords) {
return (keywords, nil)
}
return (nil, PersistenceAlert.LOAD_FAILED)
func getTags() async -> [RecipeKeyword]? {
return await load(path: .keywords)
}
func getRecipesTagged(keyword: String) async -> ([RecipeStub]?, (any UserAlert)?) {
return (nil, PersistenceAlert.LOAD_FAILED)
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return nil
}
}
@@ -109,6 +96,7 @@ extension LocalDataInterface: WriteInterface {
path: LocalDataPath.image(id: id, size: size)
)
}
return nil
}
func postRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
@@ -129,8 +117,9 @@ extension LocalDataInterface: WriteInterface {
guard let stubs: [RecipeStub] = await load(path: .recipeStubs(category: categoryName)) else {
return PersistenceAlert.LOAD_FAILED
}
await delete(path: .recipeStubs(category: categoryName))
await save(stubs, path: .recipeStubs(category: newName))
await delete(path: .recipeStubs(category: categoryName))
return nil
}
}
@@ -159,3 +148,4 @@ extension LocalDataInterface {
}
}
*/

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
/*
class NextcloudDataInterface: CookbookInterface {
var id: String
@@ -31,39 +31,35 @@ class NextcloudDataInterface: CookbookInterface {
// MARK: - Nextcloud Read Interface
extension NextcloudDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, UserAlert?) {
let (image, error) = await api.getImage(auth: auth.token, id: id, size: size)
if let image {
return (image, nil)
}
return (nil, error)
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
return await api.getImage(auth: auth.token, id: id, size: size).0
}
func getRecipeStubs() async -> ([RecipeStub]?, UserAlert?) {
return await api.getRecipes(auth: auth.token)
func getRecipeStubs() async -> [RecipeStub]? {
return await api.getRecipes(auth: auth.token).0
}
func getRecipe(id: String) async -> (Recipe?, UserAlert?) {
return await api.getRecipe(auth: auth.token, id: id)
func getRecipe(id: String) async -> Recipe?{
return await api.getRecipe(auth: auth.token, id: id).0
}
func getCategories() async -> ([Category]?, UserAlert?) {
return await api.getCategories(auth: auth.token)
func getCategories() async -> [Category]? {
return await api.getCategories(auth: auth.token).0
}
func getRecipeStubsForCategory(named categoryName: String) async -> ([RecipeStub]?, UserAlert?) {
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await api.getCategory(
auth: UserSettings.shared.authString,
named: categoryName
)
).0
}
func getTags() async -> ([RecipeKeyword]?, (any UserAlert)?) {
return await api.getTags(auth: auth.token)
func getTags() async -> [RecipeKeyword]? {
return await api.getTags(auth: auth.token).0
}
func getRecipesTagged(keyword: String) async -> ([RecipeStub]?, UserAlert?) {
return await api.getRecipesTagged(auth: auth.token, keyword: keyword)
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return await api.getRecipesTagged(auth: auth.token, keyword: keyword).0
}
}
@@ -93,3 +89,4 @@ extension NextcloudDataInterface: WriteInterface {
}
}
*/

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeExporter {
func createPDF(recipe: RecipeDetail, image: UIImage?) -> URL? {
func createPDF(recipe: CookbookApiRecipeDetailV1, image: UIImage?) -> URL? {
let document = PDFDocument(format: .a4)
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
@@ -82,7 +82,7 @@ class RecipeExporter {
}
}
func createText(recipe: RecipeDetail) -> String {
func createText(recipe: CookbookApiRecipeDetailV1) -> String {
var recipeString = ""
recipeString.append("" + recipe.name + "\n")
recipeString.append(recipe.description + "\n\n")
@@ -99,7 +99,7 @@ class RecipeExporter {
return recipeString
}
func createJson(recipe: RecipeDetail) -> Data? {
func createJson(recipe: CookbookApiRecipeDetailV1) -> Data? {
return JSONEncoder.safeEncode(recipe)
}
}

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeScraper {
func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportAlert?) {
func scrape(url: String) async throws -> (CookbookApiRecipeDetailV1?, RecipeImportAlert?) {
var contents: String? = nil
if let url = URL(string: url) {
do {
@@ -77,9 +77,9 @@ class RecipeScraper {
}
}
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> RecipeDetail? {
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> CookbookApiRecipeDetailV1? {
var recipeDetail = RecipeDetail()
var recipeDetail = CookbookApiRecipeDetailV1()
recipeDetail.name = recipe["name"] as? String ?? "New Recipe"
recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? ""
recipeDetail.keywords = joinedStringForKey("keywords", dict: recipe)

View File

@@ -146,3 +146,33 @@ enum RequestAlert: UserAlert {
return [.OK]
}
}
enum PersistenceAlert: UserAlert {
case DECODING_FAILED,
ENCODING_FAILED,
SAVE_FAILED,
LOAD_FAILED
var localizedDescription: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Unable to decode recipe data."
case .ENCODING_FAILED: return "Unable to encode recipe data."
case .SAVE_FAILED: return "Unable to save recipe."
case .LOAD_FAILED: return "Unable to load recipe."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Decoding Error"
case .ENCODING_FAILED: return "Encoding Error"
case .SAVE_FAILED: return "Error"
case .LOAD_FAILED: return "Error"
}
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -37,7 +37,28 @@ class DurationComponents: ObservableObject {
}
}
init() {
}
init(_ hours: Int, _ min: Int, _ sec: Int = 0) {
self.hourComponent = hours
self.minuteComponent = min
self.secondComponent = sec
}
required init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let durationString = try container.decode(String.self)
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = durationString.firstMatch(of: hourRegex) {
self.hourComponent = Int(match.1) ?? 0
}
if let match = durationString.firstMatch(of: minuteRegex) {
self.minuteComponent = Int(match.1) ?? 0
}
}
var displayString: String {
if hourComponent != 0 && minuteComponent != 0 {
@@ -144,3 +165,11 @@ class DurationComponents: ObservableObject {
return result
}
}
extension DurationComponents: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let durationString = toPTString()
try container.encode(durationString)
}
}

View File

@@ -6,68 +6,177 @@
//
import SwiftUI
import SwiftData
struct MainView: View {
@StateObject var appState = AppState()
@StateObject var groceryList = GroceryList()
//@State var cookbookState: CookbookState = CookbookState()
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe] = []
// Tab ViewModels
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
@StateObject var searchViewModel = SearchTabView.ViewModel()
enum Tab {
case recipes, search, groceryList
}
var body: some View {
TabView {
RecipeTabView()
.environmentObject(recipeViewModel)
.environmentObject(appState)
.environmentObject(groceryList)
.tabItem {
Label("Recipes", systemImage: "book.closed.fill")
}
.tag(Tab.recipes)
SearchTabView()
.environmentObject(searchViewModel)
.environmentObject(appState)
.environmentObject(groceryList)
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
GroceryListTabView()
.environmentObject(groceryList)
.tabItem {
if #available(iOS 17.0, *) {
Label("Grocery List", systemImage: "storefront")
} else {
Label("Grocery List", systemImage: "heart.text.square")
}
}
.tag(Tab.groceryList)
}
.task {
recipeViewModel.presentLoadingIndicator = true
await appState.getCategories()
await appState.updateAllRecipeDetails()
// Open detail view for default category
if UserSettings.shared.defaultCategory != "" {
if let cat = appState.categories.first(where: { c in
if c.name == UserSettings.shared.defaultCategory {
return true
}
return false
}) {
recipeViewModel.selectedCategory = cat
VStack {
List {
ForEach(recipes) { recipe in
Text(recipe.name)
}
}
await groceryList.load()
recipeViewModel.presentLoadingIndicator = false
Button("New") {
let recipe = Recipe(id: UUID().uuidString, name: "Neues Rezept", keywords: [], prepTime: "", cookTime: "", totalTime: "", recipeDescription: "", yield: 0, category: "", tools: [], ingredients: [], instructions: [], nutrition: [:], ingredientMultiplier: 0)
modelContext.insert(recipe)
}
}
/*NavigationSplitView {
VStack {
List(selection: $cookbookState.selectedCategory) {
ForEach(cookbookState.categories) { category in
Text(category.name)
.tag(category)
}
}
.listStyle(.plain)
.onAppear {
Task {
await cookbookState.loadCategories()
}
}
}
} content: {
if let selectedCategory = cookbookState.selectedCategory {
List(selection: $cookbookState.selectedRecipeStub) {
ForEach(cookbookState.recipeStubs[selectedCategory.name] ?? [], id: \.id) { recipeStub in
Text(recipeStub.title)
.tag(recipeStub)
}
}
.onAppear {
Task {
await cookbookState.loadRecipeStubs(category: selectedCategory.name)
}
}
} else {
Text("Please select a category.")
.foregroundColor(.secondary)
}
} detail: {
if let selectedRecipe = cookbookState.selectedRecipe {
if let recipe = cookbookState.recipes[selectedRecipe.id] {
RecipeView(recipe: recipe)
} else {
ProgressView()
.onAppear {
Task {
await cookbookState.loadRecipe(id: selectedRecipe.id)
}
}
}
} else {
Text("Please select a recipe.")
.foregroundColor(.secondary)
}
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: {
cookbookState.showGroceries = true
}) {
Label("Grocery List", systemImage: "cart")
}
}
ToolbarItem(placement: .topBarLeading) {
Button(action: {
cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
}
/*struct CategoryListView: View {
@Bindable var cookbookState: CookbookState
var body: some View {
List(cookbookState.selectedAccountState.categories) { category in
NavigationLink {
RecipeListView(
cookbookState: cookbookState,
selectedCategory: category.name,
showEditView: .constant(false)
)
} label: {
HStack(alignment: .center) {
if cookbookState.selectedAccountState.selectedCategory != nil &&
category.name == cookbookState.selectedAccountState.selectedCategory?.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
}
}*/
/*struct CategoryListView: View {
@State var state: CookbookState
var body: some View {
List(selection: $state.categoryListSelection) {
ForEach(state.categories) { category in
NavigationLink(value: category) {
HStack(alignment: .center) {
if state.categoryListSelection != nil &&
category.name == state.categoryListSelection {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
}
}
}*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct OnboardingView: View {
@State var selectedTab: Int = 0
@@ -244,3 +244,4 @@ struct ServerAddressField_Preview: PreviewProvider {
.background(Color.nextcloudBlue)
}
}
*/

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
/*
struct TokenLoginView: View {
@Binding var showAlert: Bool
@Binding var alertMessage: String
@@ -105,3 +105,4 @@ struct TokenLoginView: View {
return true
}
}
*/

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
import WebKit
/*
enum V2LoginStage: LoginStage {
case login, validate
@@ -82,7 +82,7 @@ struct V2LoginView: View {
Task {
let error = await sendLoginV2Request()
if let error = error {
alertMessage = "A network error occured (\(error.rawValue))."
alertMessage = "A network error occured (\(error.localizedDescription))."
showAlert = true
}
if let loginRequest = loginRequest {
@@ -157,7 +157,7 @@ struct V2LoginView: View {
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
if let error = error {
alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))"
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
showAlert = true
return
}
@@ -209,3 +209,4 @@ struct WebView: UIViewRepresentable {
uiView.load(request)
}
}
*/

View File

@@ -8,9 +8,10 @@
import Foundation
import SwiftUI
/*
struct RecipeCardView: View {
@EnvironmentObject var appState: AppState
@State var recipe: Recipe
@State var recipe: CookbookApiRecipeV1
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
@@ -69,3 +70,63 @@ struct RecipeCardView: View {
.frame(height: 80)
}
}
*/
/*
struct RecipeCardView: View {
@State var state: AccountState
@State var recipe: RecipeStub
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
var body: some View {
HStack {
if let recipeThumb = recipeThumb {
Image(uiImage: recipeThumb)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
} else {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
.background(Color("ncblue"))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
}
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer()
if let isDownloaded = isDownloaded {
VStack {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
}
}
}
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.task {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
)
isDownloaded = recipe.storedLocally
}
.refreshable {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
)
}
.frame(height: 80)
}
}
*/

View File

@@ -9,14 +9,14 @@ import Foundation
import SwiftUI
/*
struct RecipeListView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@State var categoryName: String
@State var searchText: String = ""
@Binding var showEditView: Bool
@State var selectedRecipe: Recipe? = nil
@State var selectedRecipe: CookbookApiRecipeV1? = nil
var body: some View {
Group {
@@ -56,7 +56,7 @@ struct RecipeListView: View {
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.navigationDestination(for: Recipe.self) { recipe in
.navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState)
.environmentObject(groceryList)
@@ -85,7 +85,7 @@ struct RecipeListView: View {
}
}
func recipesFiltered() -> [Recipe] {
func recipesFiltered() -> [CookbookApiRecipeV1] {
guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
@@ -94,3 +94,86 @@ struct RecipeListView: View {
}
}
}
*/
/*
struct RecipeListView: View {
@Bindable var cookbookState: CookbookState
@State var selectedCategory: String
@State var searchText: String = ""
@Binding var showEditView: Bool
var body: some View {
Group {
let recipes = recipesFiltered()
if !recipes.isEmpty {
List(recipesFiltered(), selection: $cookbookState.selectedAccountState.selectedRecipe) { recipe in
RecipeCardView(state: cookbookState.selectedAccountState, recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink {
RecipeView(viewModel: RecipeView.ViewModel(recipeStub: recipe))
.environment(cookbookState)
} label: {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
.listStyle(.plain)
} else {
VStack {
Text("There are no recipes in this cookbook!")
Button {
Task {
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: selectedCategory)
}
} label: {
Text("Refresh")
.bold()
}
.buttonStyle(.bordered)
}.padding()
}
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(selectedCategory == "*" ? String(localized: "Other") : selectedCategory)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Add new recipe")
showEditView = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
}
.task {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
.refreshable {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
}
func recipesFiltered() -> [RecipeStub] {
guard let recipes = cookbookState.selectedAccountState.recipeStubs[selectedCategory] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
}
}
}
*/

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
/*
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@@ -164,7 +164,7 @@ struct RecipeView: View {
let recipeDetail = await appState.getRecipe(
id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error
) ?? CookbookApiRecipeDetailV1.error
viewModel.setupView(recipeDetail: recipeDetail)
// Show download badge
@@ -182,7 +182,7 @@ struct RecipeView: View {
} else {
// Prepare view for a new recipe
viewModel.setupView(recipeDetail: RecipeDetail())
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.editMode = true
viewModel.isDownloaded = false
}
@@ -231,8 +231,8 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel
class ViewModel: ObservableObject {
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var observableRecipeDetail: Recipe = Recipe()
@Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@@ -244,7 +244,7 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe
var recipe: CookbookApiRecipeV1
var sharedURL: URL? = nil
var newRecipe: Bool = false
@@ -254,13 +254,13 @@ struct RecipeView: View {
var alertAction: () async -> () = { }
// Initializers
init(recipe: Recipe) {
init(recipe: CookbookApiRecipeV1) {
self.recipe = recipe
}
init() {
self.newRecipe = true
self.recipe = Recipe(
self.recipe = CookbookApiRecipeV1(
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
@@ -271,9 +271,9 @@ struct RecipeView: View {
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
func setupView(recipeDetail: CookbookApiRecipeDetailV1) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
self.observableRecipeDetail = Recipe(recipeDetail)
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -458,4 +458,434 @@ struct RecipeViewToolBar: ToolbarContent {
}
}
*/
/*
struct RecipeView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
}
private enum CoordinateSpaces {
case scrollView
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.recipe.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipe.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.recipe.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.recipe.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.recipe.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.recipe.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.recipe.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.recipe.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.recipe.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if let recipeStub = viewModel.recipeStub {
// For existing recipes, load the recipeDetail and image
let recipe = await cookbookState.selectedAccountState.getRecipe(
id: recipeStub.id
) ?? Recipe()
viewModel.recipe = recipe
// Show download badge
/*if viewModel.recipeStub!.storedLocally == nil {
viewModel.recipeStub?.storedLocally = cookbookState.selectedAccountState.recipeDetailExists(
recipeId: viewModel.recipe.recipe_id
)
}
viewModel.isDownloaded = viewModel.recipeStub!.storedLocally
*/
// Load recipe image
viewModel.recipeImage = await cookbookState.selectedAccountState.getImage(
id: recipeStub.id,
size: .FULL
)
} else {
// Prepare view for a new recipe
viewModel.editMode = true
viewModel.isDownloaded = false
viewModel.recipe = Recipe()
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: viewModel.editMode) { newValue in
if newValue && cookbookState.selectedAccountState.keywords.isEmpty {
Task {
if let keywords = await cookbookState.selectedAccountState.getTags()?.sorted(by: { a, b in
a.recipe_count > b.recipe_count
}) {
cookbookState.selectedAccountState.keywords = keywords
}
}
}
}
}
// MARK: - RecipeView ViewModel
@Observable class ViewModel {
var recipeImage: UIImage? = nil
var editMode: Bool = false
var showTitle: Bool = false
var isDownloaded: Bool? = nil
var importUrl: String = ""
var presentShareSheet: Bool = false
var presentInstructionEditView: Bool = false
var presentIngredientEditView: Bool = false
var presentToolEditView: Bool = false
var recipeStub: RecipeStub? = nil
var recipe: Recipe = Recipe()
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipeStub: RecipeStub) {
self.recipeStub = recipeStub
}
init() {
self.newRecipe = true
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
/*let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}*/
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.recipe = scrapedRecipe.toRecipe()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await handleUpload()
}
} label: {
if viewModel.newRecipe {
Text("Upload Recipe")
} else {
Text("Upload Changes")
}
}
}
} else {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.editMode = true
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
print("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await cookbookState.selectedAccountState.postRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
if let alert = await cookbookState.selectedAccountState.updateRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: viewModel.recipe.recipeCategory)
let _ = await cookbookState.selectedAccountState.getRecipe(id: viewModel.recipe.id)
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.recipe.recipeCategory
if let alert = await cookbookState.selectedAccountState.deleteRecipe(id: viewModel.recipe.id) {
viewModel.presentAlert(alert)
return
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: category)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in cookbookState.selectedAccountState.recipeStubs.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}
*/

View File

@@ -9,17 +9,17 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Duration Section
/*
struct RecipeDurationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
@State var presentPopover: Bool = false
var body: some View {
VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
DurationView(time: viewModel.recipe.prepTime, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.recipe.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.recipe.totalTime, title: LocalizedStringKey("Total time"))
}
if viewModel.editMode {
Button {
@@ -34,9 +34,9 @@ struct RecipeDurationSection: View {
.padding()
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
prepTime: viewModel.recipe.prepTime,
cookTime: viewModel.recipe.cookTime,
totalTime: viewModel.recipe.totalTime
)
}
}
@@ -94,10 +94,10 @@ fileprivate struct EditableDurationView: View {
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
}
.padding()
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
.onChange(of: prepTime.hourComponent) { updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { updateTotalTime() }
.onChange(of: cookTime.hourComponent) { updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { updateTotalTime() }
}
}
@@ -142,3 +142,5 @@ fileprivate struct TimePickerView: View {
.padding()
}
}
*/

View File

@@ -10,9 +10,9 @@ import SwiftUI
// MARK: - RecipeView Import Section
/*
struct RecipeImportSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
var importRecipe: (String) async -> UserAlert?
var body: some View {
@@ -49,4 +49,4 @@ struct RecipeImportSection: View {
.padding(.top, 5)
}
}
*/

View File

@@ -9,23 +9,23 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Ingredients Section
/*
struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@ObservedObject var viewModel: RecipeView.ViewModel
@Environment(CookbookState.self) var cookbookState
@State var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
Button {
withAnimation {
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
} else {
groceryList.addItems(
viewModel.observableRecipeDetail.recipeIngredient,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
cookbookState.groceryList.addItems(
viewModel.recipe.recipeIngredient,
toRecipe: viewModel.recipe.id,
recipeName: viewModel.recipe.name
)
}
}
@@ -45,26 +45,26 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary)
.bold()
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
ServingPickerView(selectedServingSize: $viewModel.recipe.ingredientMultiplier)
}
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
ForEach(0..<viewModel.recipe.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id
ingredient: $viewModel.recipe.recipeIngredient[ix],
servings: $viewModel.recipe.ingredientMultiplier,
recipeYield: Double(viewModel.recipe.recipeYield),
recipeId: viewModel.recipe.id
) {
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
cookbookState.groceryList.addItem(
viewModel.recipe.recipeIngredient[ix],
toRecipe: viewModel.recipe.id,
recipeName: viewModel.recipe.name
)
}
.padding(4)
}
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
if viewModel.recipe.ingredientMultiplier != Double(viewModel.recipe.recipeYield) {
HStack() {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary)
@@ -83,14 +83,14 @@ struct RecipeIngredientSection: View {
}
}
.padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
.animation(.easeInOut, value: viewModel.recipe.ingredientMultiplier)
}
}
// MARK: - RecipeIngredientSection List Item
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@Environment(CookbookState.self) var cookbookState
@Binding var ingredient: String
@Binding var servings: Double
@State var recipeYield: Double
@@ -110,7 +110,7 @@ fileprivate struct IngredientListItem: View {
var body: some View {
HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient) {
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
@@ -140,11 +140,11 @@ fileprivate struct IngredientListItem: View {
}
Spacer()
}
.onChange(of: servings) { newServings in
.onChange(of: servings) { _, newServings in
if recipeYield == 0 {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings)
} else {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings/recipeYield)
}
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
@@ -168,8 +168,8 @@ fileprivate struct IngredientListItem: View {
.onEnded { gesture in
withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if groceryList.containsItem(at: recipeId, item: ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
}
@@ -209,9 +209,12 @@ struct ServingPickerView: View {
.bold()
}
}
.onChange(of: selectedServingSize) { newValue in
.onChange(of: selectedServingSize) { _, newValue in
if newValue < 0 { selectedServingSize = 0 }
else if newValue > 100 { selectedServingSize = 100 }
}
}
}
*/

View File

@@ -9,9 +9,9 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Instructions Section
/*
struct RecipeInstructionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
@@ -19,8 +19,8 @@ struct RecipeInstructionSection: View {
SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer()
}
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
ForEach(viewModel.recipe.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.recipe.recipeInstructions[ix], index: ix+1)
}
if viewModel.editMode {
Button {
@@ -56,4 +56,4 @@ fileprivate struct RecipeInstructionListItem: View {
.animation(.easeInOut, value: isSelected)
}
}
*/

View File

@@ -9,16 +9,16 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Keyword Section
/*
struct RecipeKeywordSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.observableRecipeDetail.keywords)
if !viewModel.recipe.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.recipe.keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
@@ -189,3 +189,4 @@ struct KeywordPickerView_Previews: PreviewProvider {
}
}
*/

View File

@@ -9,14 +9,14 @@ import Foundation
import SwiftUI
// MARK: - Recipe Metadata Section
/*
struct RecipeMetadataSection: View {
@EnvironmentObject var appState: AppState
@ObservedObject var viewModel: RecipeView.ViewModel
@Environment(CookbookState.self) var cookbookState
@State var viewModel: RecipeView.ViewModel
@State var keywords: [RecipeKeyword] = []
var categories: [String] {
appState.categories.map({ category in category.name })
cookbookState.selectedAccountState.categories.map({ category in category.name })
}
@State var presentKeywordSheet: Bool = false
@@ -28,11 +28,11 @@ struct RecipeMetadataSection: View {
// Category
SecondaryLabel(text: "Category")
HStack {
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
TextField("Category", text: $viewModel.recipe.recipeCategory)
.lineLimit(1)
.textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Picker("Choose", selection: $viewModel.recipe.recipeCategory) {
Text("").tag("")
ForEach(categories, id: \.self) { item in
Text(item)
@@ -45,10 +45,10 @@ struct RecipeMetadataSection: View {
// Keywords
SecondaryLabel(text: "Keywords")
if !viewModel.observableRecipeDetail.keywords.isEmpty {
if !viewModel.recipe.keywords.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
ForEach(viewModel.recipe.keywords, id: \.self) { keyword in
Text(keyword)
.padding(5)
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
@@ -70,11 +70,11 @@ struct RecipeMetadataSection: View {
Button {
presentServingsPopover.toggle()
} label: {
Text("\(viewModel.observableRecipeDetail.recipeYield) Serving(s)")
Text("\(viewModel.recipe.recipeYield) Serving(s)")
.lineLimit(1)
}
.popover(isPresented: $presentServingsPopover) {
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.recipe.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
}
}
}
@@ -82,7 +82,7 @@ struct RecipeMetadataSection: View {
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
.padding([.horizontal, .bottom], 5)
.sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
KeywordPickerView(title: "Keywords", searchSuggestions: cookbookState.selectedAccountState.keywords, selection: $viewModel.recipe.keywords)
}
}
}
@@ -126,22 +126,22 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
// MARK: - RecipeView More Information Section
struct MoreInformationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) {
if let dateCreated = viewModel.recipeDetail.dateCreated {
if let dateCreated = viewModel.recipe.dateCreated {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
}
if let dateModified = viewModel.recipeDetail.dateModified {
if let dateModified = viewModel.recipe.dateModified {
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
}
if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) {
if viewModel.recipe.url != "", let url = URL(string: viewModel.recipe.url ?? "") {
HStack(alignment: .top) {
Text("URL:")
Link(destination: url) {
Text(viewModel.observableRecipeDetail.url)
Text(viewModel.recipe.url ?? "")
}
}
}
@@ -157,3 +157,5 @@ struct MoreInformationSection: View {
.padding()
}
}
*/

View File

@@ -9,9 +9,9 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Nutrition Section
/*
struct RecipeNutritionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
@@ -28,7 +28,7 @@ struct RecipeNutritionSection: View {
} else if !nutritionEmpty() {
VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
if let value = viewModel.recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading)
@@ -43,7 +43,7 @@ struct RecipeNutritionSection: View {
}
} title: {
HStack {
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
if let servingSize = viewModel.recipe.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -56,17 +56,19 @@ struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> {
Binding(
get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] },
set: { viewModel.observableRecipeDetail.nutrition[key] = $0 }
get: { viewModel.recipe.nutrition[key, default: ""] },
set: { viewModel.recipe.nutrition[key] = $0 }
)
}
func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
if let value = viewModel.recipe.nutrition[nutrition.dictKey] {
return false
}
}
return true
}
}
*/

View File

@@ -9,9 +9,9 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Tool Section
/*
struct RecipeToolSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
@@ -20,7 +20,7 @@ struct RecipeToolSection: View {
Spacer()
}
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
RecipeListSection(list: $viewModel.recipe.tool)
if viewModel.editMode {
Button {
@@ -35,3 +35,5 @@ struct RecipeToolSection: View {
}
*/

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct ShareView: View {
@State var recipeDetail: RecipeDetail
@State var recipeDetail: CookbookApiRecipeDetailV1
@State var recipeImage: UIImage?
@Binding var presentShareSheet: Bool

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct CollapsibleView<C: View, T: View>: View {
@State var titleColor: Color = .white
@State var isCollapsed: Bool = true
@@ -48,3 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
}
}
}
*/

View File

@@ -9,8 +9,12 @@ import Foundation
import SwiftUI
struct SettingsView: View {
var body: some View {
Text("Settings")
}
}
/*struct SettingsView: View {
@EnvironmentObject var appState: AppState
@ObservedObject var userSettings = UserSettings.shared
@ObservedObject var viewModel = ViewModel()
@@ -248,3 +252,4 @@ extension SettingsView {
*/

View File

@@ -8,36 +8,34 @@
import Foundation
import SwiftUI
/*
struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList
@Environment(CookbookState.self) var cookbookState
var body: some View {
NavigationStack {
if groceryList.groceryDict.isEmpty {
if cookbookState.groceryList.groceryDict.isEmpty {
EmptyGroceryListView()
} else {
List {
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
ForEach(cookbookState.groceryList.groceryDict.keys.sorted(), id: \.self) { key in
Section {
ForEach(groceryList.groceryDict[key]!.items) { item in
ForEach(cookbookState.groceryList.groceryDict[key]!.items) { item in
GroceryListItemView(item: item, toggleAction: {
groceryList.toggleItemChecked(item)
groceryList.objectWillChange.send()
cookbookState.groceryList.toggleItemChecked(item)
}, deleteAction: {
groceryList.deleteItem(item.name, fromRecipe: key)
withAnimation {
groceryList.objectWillChange.send()
cookbookState.groceryList.deleteItem(item.name, fromRecipe: key)
}
})
}
} header: {
HStack {
Text(groceryList.groceryDict[key]!.name)
Text(cookbookState.groceryList.groceryDict[key]!.name)
.foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
groceryList.deleteGroceryRecipe(key)
cookbookState.groceryList.deleteGroceryRecipe(key)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue)
@@ -51,7 +49,7 @@ struct GroceryListTabView: View {
.navigationTitle("Grocery List")
.toolbar {
Button {
groceryList.deleteAll()
cookbookState.groceryList.deleteAll()
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
@@ -143,25 +141,22 @@ class GroceryRecipeItem: Identifiable, Codable {
@MainActor class GroceryList: ObservableObject {
@Observable class GroceryList {
let dataStore: DataStore = DataStore()
@Published var groceryDict: [String: GroceryRecipe] = [:]
@Published var sortBySimilarity: Bool = false
var groceryDict: [String: GroceryRecipe] = [:]
var sortBySimilarity: Bool = false
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))")
DispatchQueue.main.async {
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
self.groceryDict[recipeId] = newRecipe
}
if saveGroceryDict {
self.save()
self.objectWillChange.send()
}
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
self.groceryDict[recipeId] = newRecipe
}
if saveGroceryDict {
self.save()
}
}
@@ -170,7 +165,6 @@ class GroceryRecipeItem: Identifiable, Codable {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
objectWillChange.send()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
@@ -182,14 +176,12 @@ class GroceryRecipeItem: Identifiable, Codable {
groceryDict.removeValue(forKey: recipeId)
}
save()
objectWillChange.send()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
objectWillChange.send()
}
func deleteAll() {
@@ -234,4 +226,4 @@ class GroceryRecipeItem: Identifiable, Codable {
}
}
*/

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
/*
struct RecipeTabView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@@ -179,3 +179,5 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
}
}
*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct SearchTabView: View {
@EnvironmentObject var viewModel: SearchTabView.ViewModel
@EnvironmentObject var appState: AppState
@@ -30,7 +30,7 @@ struct SearchTabView: View {
.listRowSeparatorTint(.clear)
}
.listStyle(.plain)
.navigationDestination(for: Recipe.self) { recipe in
.navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
}
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
@@ -48,7 +48,7 @@ struct SearchTabView: View {
}
class ViewModel: ObservableObject {
@Published var allRecipes: [Recipe] = []
@Published var allRecipes: [CookbookApiRecipeV1] = []
@Published var searchText: String = ""
@Published var searchMode: SearchMode = .name
@@ -58,7 +58,7 @@ struct SearchTabView: View {
case name = "Name & Keywords", ingredient = "Ingredients"
}
func recipesFiltered() -> [Recipe] {
func recipesFiltered() -> [CookbookApiRecipeV1] {
if searchMode == .name {
guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in
@@ -72,3 +72,4 @@ struct SearchTabView: View {
}
}
}
*/