Network layer: - Replace static CookbookApi protocol with instance-based CookbookApiProtocol using async/throws instead of tuple returns - Refactor ApiRequest to use URLComponents for proper URL encoding, replace print statements with OSLog, and return typed NetworkError cases - Add structured NetworkError variants (httpError, connectionError, etc.) - Remove global cookbookApi constant in favor of injected dependency on AppState - Delete unused RecipeEditViewModel, RecipeScraper, and Scraper playground Data & model fixes: - Add custom Decodable for RecipeDetail with safe fallbacks for malformed JSON - Make Category Hashable/Equatable use only `name` so NavigationSplitView selection survives category refreshes with updated recipe_count - Return server-assigned ID from uploadRecipe so new recipes get their ID before the post-upload refresh block executes View updates: - Refresh both old and new category recipe lists after upload when category changes, mapping empty recipeCategory to "*" for uncategorized recipes - Raise deployment target to iOS 18, adopt new SwiftUI API conventions - Clean up alerts, onboarding views, and settings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
285 lines
10 KiB
Swift
285 lines
10 KiB
Swift
//
|
|
// RecipeModels.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 17.02.24.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
|
|
struct Recipe: Codable {
|
|
let name: String
|
|
let keywords: String?
|
|
let dateCreated: String?
|
|
let dateModified: String?
|
|
let imageUrl: String?
|
|
let imagePlaceholderUrl: String?
|
|
let recipe_id: Int
|
|
|
|
// Properties excluded from Codable
|
|
var storedLocally: Bool? = nil
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
|
|
}
|
|
}
|
|
|
|
|
|
extension Recipe: Identifiable, Hashable {
|
|
var id: Int { recipe_id }
|
|
}
|
|
|
|
|
|
struct RecipeDetail: Codable {
|
|
var name: String
|
|
var keywords: String
|
|
var dateCreated: String?
|
|
var dateModified: String?
|
|
var imageUrl: String?
|
|
var id: String
|
|
var prepTime: String?
|
|
var cookTime: String?
|
|
var totalTime: String?
|
|
var description: String
|
|
var url: String?
|
|
var recipeYield: Int
|
|
var recipeCategory: String
|
|
var tool: [String]
|
|
var recipeIngredient: [String]
|
|
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]) {
|
|
self.name = name
|
|
self.keywords = keywords
|
|
self.dateCreated = dateCreated
|
|
self.dateModified = dateModified
|
|
self.imageUrl = imageUrl
|
|
self.id = id
|
|
self.prepTime = prepTime
|
|
self.cookTime = cookTime
|
|
self.totalTime = totalTime
|
|
self.description = description
|
|
self.url = url
|
|
self.recipeYield = recipeYield
|
|
self.recipeCategory = recipeCategory
|
|
self.tool = tool
|
|
self.recipeIngredient = recipeIngredient
|
|
self.recipeInstructions = recipeInstructions
|
|
self.nutrition = nutrition
|
|
}
|
|
|
|
init() {
|
|
name = ""
|
|
keywords = ""
|
|
dateCreated = ""
|
|
dateModified = ""
|
|
imageUrl = ""
|
|
id = ""
|
|
prepTime = ""
|
|
cookTime = ""
|
|
totalTime = ""
|
|
description = ""
|
|
url = ""
|
|
recipeYield = 0
|
|
recipeCategory = ""
|
|
tool = []
|
|
recipeIngredient = []
|
|
recipeInstructions = []
|
|
nutrition = [:]
|
|
}
|
|
|
|
// Custom decoder to handle value type ambiguity
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
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)
|
|
// Server import returns "image"; show/index responses and local storage use "imageUrl"
|
|
imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image))
|
|
?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl))
|
|
id = try container.decode(String.self, forKey: .id)
|
|
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
|
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
|
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
|
description = (try? container.decode(String.self, forKey: .description)) ?? ""
|
|
url = try? container.decode(String.self, forKey: .url)
|
|
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
|
|
|
|
// recipeYield: try Int first, then parse leading digits from String
|
|
if let yieldInt = try? container.decode(Int.self, forKey: .recipeYield) {
|
|
recipeYield = yieldInt
|
|
} else if let yieldString = try? container.decode(String.self, forKey: .recipeYield) {
|
|
let digits = yieldString.prefix(while: { $0.isNumber })
|
|
recipeYield = Int(digits) ?? 0
|
|
} else {
|
|
recipeYield = 0
|
|
}
|
|
|
|
tool = (try? container.decode([String].self, forKey: .tool)) ?? []
|
|
recipeIngredient = (try? container.decode([String].self, forKey: .recipeIngredient)) ?? []
|
|
recipeInstructions = (try? container.decode([String].self, forKey: .recipeInstructions)) ?? []
|
|
|
|
if let nutritionDict = try? container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition) {
|
|
nutrition = nutritionDict.mapValues { String(describing: $0.value) }
|
|
} else {
|
|
nutrition = [:]
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(name, forKey: .name)
|
|
try container.encode(keywords, forKey: .keywords)
|
|
try container.encodeIfPresent(dateCreated, forKey: .dateCreated)
|
|
try container.encodeIfPresent(dateModified, forKey: .dateModified)
|
|
// Encode under "image" — the key the server expects for create/update
|
|
try container.encodeIfPresent(imageUrl, forKey: .image)
|
|
try container.encode(id, forKey: .id)
|
|
try container.encodeIfPresent(prepTime, forKey: .prepTime)
|
|
try container.encodeIfPresent(cookTime, forKey: .cookTime)
|
|
try container.encodeIfPresent(totalTime, forKey: .totalTime)
|
|
try container.encode(description, forKey: .description)
|
|
try container.encodeIfPresent(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)
|
|
}
|
|
}
|
|
|
|
|
|
extension RecipeDetail {
|
|
static var error: RecipeDetail {
|
|
return RecipeDetail(
|
|
name: "Error: Unable to load recipe.",
|
|
keywords: "",
|
|
dateCreated: "",
|
|
dateModified: "",
|
|
imageUrl: "",
|
|
id: "",
|
|
prepTime: "",
|
|
cookTime: "",
|
|
totalTime: "",
|
|
description: "",
|
|
url: "",
|
|
recipeYield: 0,
|
|
recipeCategory: "",
|
|
tool: [],
|
|
recipeIngredient: [],
|
|
recipeInstructions: [],
|
|
nutrition: [:]
|
|
)
|
|
}
|
|
|
|
func getKeywordsArray() -> [String] {
|
|
if keywords == "" { return [] }
|
|
return keywords.components(separatedBy: ",")
|
|
}
|
|
|
|
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
|
|
if !keywordsArray.isEmpty {
|
|
self.keywords = keywordsArray.joined(separator: ",")
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
struct RecipeImage {
|
|
enum RecipeImageSize: String {
|
|
case THUMB="thumb", FULL="full"
|
|
}
|
|
var imageExists: Bool = true
|
|
var thumb: UIImage?
|
|
var full: UIImage?
|
|
}
|
|
|
|
|
|
struct RecipeKeyword: Codable {
|
|
let name: String
|
|
let recipe_count: Int
|
|
}
|
|
|
|
|
|
enum Nutrition: CaseIterable {
|
|
case servingSize,
|
|
calories,
|
|
carbohydrateContent,
|
|
cholesterolContent,
|
|
fatContent,
|
|
saturatedFatContent,
|
|
unsaturatedFatContent,
|
|
transFatContent,
|
|
fiberContent,
|
|
proteinContent,
|
|
sodiumContent,
|
|
sugarContent
|
|
|
|
var localizedDescription: String {
|
|
switch self {
|
|
case .servingSize:
|
|
return NSLocalizedString("Serving size", comment: "Serving size")
|
|
case .calories:
|
|
return NSLocalizedString("Calories", comment: "Calories")
|
|
case .carbohydrateContent:
|
|
return NSLocalizedString("Carbohydrate content", comment: "Carbohydrate content")
|
|
case .cholesterolContent:
|
|
return NSLocalizedString("Cholesterol content", comment: "Cholesterol content")
|
|
case .fatContent:
|
|
return NSLocalizedString("Fat content", comment: "Fat content")
|
|
case .saturatedFatContent:
|
|
return NSLocalizedString("Saturated fat content", comment: "Saturated fat content")
|
|
case .unsaturatedFatContent:
|
|
return NSLocalizedString("Unsaturated fat content", comment: "Unsaturated fat content")
|
|
case .transFatContent:
|
|
return NSLocalizedString("Trans fat content", comment: "Trans fat content")
|
|
case .fiberContent:
|
|
return NSLocalizedString("Fiber content", comment: "Fiber content")
|
|
case .proteinContent:
|
|
return NSLocalizedString("Protein content", comment: "Protein content")
|
|
case .sodiumContent:
|
|
return NSLocalizedString("Sodium content", comment: "Sodium content")
|
|
case .sugarContent:
|
|
return NSLocalizedString("Sugar content", comment: "Sugar content")
|
|
}
|
|
}
|
|
|
|
var dictKey: String {
|
|
switch self {
|
|
case .servingSize:
|
|
"servingSize"
|
|
case .calories:
|
|
"calories"
|
|
case .carbohydrateContent:
|
|
"carbohydrateContent"
|
|
case .cholesterolContent:
|
|
"cholesterolContent"
|
|
case .fatContent:
|
|
"fatContent"
|
|
case .saturatedFatContent:
|
|
"saturatedFatContent"
|
|
case .unsaturatedFatContent:
|
|
"unsaturatedFatContent"
|
|
case .transFatContent:
|
|
"transFatContent"
|
|
case .fiberContent:
|
|
"fiberContent"
|
|
case .proteinContent:
|
|
"proteinContent"
|
|
case .sodiumContent:
|
|
"sodiumContent"
|
|
case .sugarContent:
|
|
"sugarContent"
|
|
}
|
|
}
|
|
}
|