Modernize networking layer and fix category navigation and recipe list bugs

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>
This commit is contained in:
2026-02-15 00:47:28 +01:00
parent 527acd2967
commit 7c824b492e
31 changed files with 534 additions and 1103 deletions

View File

@@ -28,7 +28,7 @@ struct Recipe: Codable {
extension Recipe: Identifiable, Hashable {
var id: String { name }
var id: Int { recipe_id }
}
@@ -93,29 +93,67 @@ struct RecipeDetail: Codable {
// Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
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)
keywords = (try? container.decode(String.self, forKey: .keywords)) ?? ""
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
// 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)
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
description = (try? container.decode(String.self, forKey: .description)) ?? ""
url = try? container.decode(String.self, forKey: .url)
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
// 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)
}
}