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:
@@ -20,6 +20,14 @@ struct Category: Codable {
|
||||
|
||||
extension Category: Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
|
||||
static func == (lhs: Category, rhs: Category) -> Bool {
|
||||
lhs.name == rhs.name
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
class DataStore {
|
||||
let fileManager = FileManager.default
|
||||
static let shared = DataStore()
|
||||
|
||||
|
||||
private static func fileURL(appending: String) throws -> URL {
|
||||
try FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
@@ -21,7 +22,7 @@ class DataStore {
|
||||
)
|
||||
.appendingPathComponent(appending)
|
||||
}
|
||||
|
||||
|
||||
private static func fileURL() throws -> URL {
|
||||
try FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
@@ -30,7 +31,7 @@ class DataStore {
|
||||
create: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func load<D: Decodable>(fromPath path: String) async throws -> D? {
|
||||
let task = Task<D?, Error> {
|
||||
let fileURL = try Self.fileURL(appending: path)
|
||||
@@ -42,7 +43,7 @@ class DataStore {
|
||||
}
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
|
||||
func save<D: Encodable>(data: D, toPath path: String) async {
|
||||
let task = Task {
|
||||
let data = try JSONEncoder().encode(data)
|
||||
@@ -52,42 +53,36 @@ class DataStore {
|
||||
do {
|
||||
_ = try await task.value
|
||||
} catch {
|
||||
print("Could not save data (path: \(path)")
|
||||
Logger.data.error("Could not save data (path: \(path))")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func delete(path: String) {
|
||||
Task {
|
||||
let fileURL = try Self.fileURL(appending: path)
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func recipeDetailExists(recipeId: Int) -> Bool {
|
||||
let filePath = "recipe\(recipeId).data"
|
||||
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
||||
return fileManager.fileExists(atPath: folderPath + filePath)
|
||||
}
|
||||
|
||||
|
||||
func clearAll() -> Bool {
|
||||
print("Attempting to delete all data ...")
|
||||
Logger.data.debug("Attempting to delete all data ...")
|
||||
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
||||
print("Folder path: ", folderPath)
|
||||
do {
|
||||
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
||||
for filePath in filePaths {
|
||||
print("File path: ", filePath)
|
||||
try fileManager.removeItem(atPath: folderPath + filePath)
|
||||
}
|
||||
} catch {
|
||||
print("Could not delete documents folder contents: \(error)")
|
||||
Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
print("Done.")
|
||||
Logger.data.debug("All data deleted successfully.")
|
||||
return true
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
class ObservableRecipeDetail: ObservableObject {
|
||||
@@ -180,7 +181,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
}
|
||||
return foundMatches
|
||||
} catch {
|
||||
print("Regex error: \(error.localizedDescription)")
|
||||
Logger.data.error("Regex error: \(error.localizedDescription)")
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user