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

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
import UIKit
@@ -19,97 +20,75 @@ import UIKit
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
var lastUpdates: [String: Date] = [:]
var allKeywords: [RecipeKeyword] = []
private let dataStore: DataStore
init() {
print("Created MainViewModel")
private let api: CookbookApiProtocol
init(api: CookbookApiProtocol? = nil) {
Logger.network.debug("Created AppState")
self.dataStore = DataStore()
self.api = api ?? CookbookApiFactory.makeClient()
if UserSettings.shared.authString == "" {
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
UserSettings.shared.authString = loginData.base64EncodedString()
}
}
enum FetchMode {
case preferLocal, preferServer, onlyLocal, onlyServer
}
/**
Asynchronously loads and updates the list of categories.
This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
// MARK: - Categories
func getCategories() async {
let (categories, _) = await cookbookApi.getCategories(
auth: UserSettings.shared.authString
)
if let categories = categories {
print("Successfully loaded categories")
do {
let categories = try await api.getCategories()
Logger.data.debug("Successfully loaded categories")
self.categories = categories
await saveLocal(self.categories, path: "categories.data")
} else {
// If there's no server connection, try loading categories from local storage
print("Loading categories from store ...")
} catch {
Logger.data.debug("Loading categories from store ...")
if let categories: [Category] = await loadLocal(path: "categories.data") {
self.categories = categories
print("Success!")
Logger.data.debug("Loaded categories from local store")
} else {
print("Failure!")
Logger.data.error("Failed to load categories from local store")
}
}
// Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories
for category in self.categories {
lastUpdates[category.name] = Date.distantPast
}
}
/**
Fetches recipes for a specified category from either the server or local storage.
- Parameters:
- name: The name of the category. Use "*" to fetch recipes without assigned categories.
- needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first.
This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage.
- Note: The category name "*" is used for all uncategorized recipes.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
func getCategory(named name: String, fetchMode: FetchMode) async {
print("getCategory(\(name), fetchMode: \(fetchMode))")
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
func getLocal() async -> Bool {
let categoryString = name == "*" ? "_" : name
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
self.recipes[name] = recipes
return true
}
return false
}
func getServer(store: Bool = false) async -> Bool {
let (recipes, _) = await cookbookApi.getCategory(
auth: UserSettings.shared.authString,
named: categoryString
)
if let recipes = recipes {
let categoryString = name == "*" ? "_" : name
do {
let recipes = try await api.getCategory(named: categoryString)
self.recipes[name] = recipes
if store {
await saveLocal(recipes, path: "category_\(categoryString).data")
}
//userSettings.lastUpdate = Date()
return true
} catch {
return false
}
return false
}
let categoryString = name == "*" ? "_" : name
switch fetchMode {
case .preferLocal:
if await getLocal() { return }
@@ -123,50 +102,38 @@ import UIKit
if await getServer() { return }
}
}
// MARK: - Recipe details
func updateAllRecipeDetails() async {
for category in self.categories {
await updateRecipeDetails(in: category.name)
}
UserSettings.shared.lastUpdate = Date()
}
func updateRecipeDetails(in category: String) async {
guard UserSettings.shared.storeRecipes else { return }
guard let recipes = self.recipes[category] else { return }
for recipe in recipes {
if let dateModified = recipe.dateModified {
if needsUpdate(category: category, lastModified: dateModified) {
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown")")
Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))")
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
} else {
print("\(recipe.name) is up to date.")
Logger.data.debug("\(recipe.name) is up to date.")
}
} else {
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
}
}
}
/**
Asynchronously retrieves all recipes either from the server or the locally cached data.
This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded.
Example usage:
```swift
let recipes = await mainViewModel.getRecipes()
*/
func getRecipes() async -> [Recipe] {
let (recipes, error) = await cookbookApi.getRecipes(
auth: UserSettings.shared.authString
)
if let recipes = recipes {
return recipes
} else if let error = error {
print(error)
do {
return try await api.getRecipes()
} catch {
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
}
var allRecipes: [Recipe] = []
for category in categories {
@@ -174,48 +141,29 @@ import UIKit
allRecipes.append(contentsOf: recipeArray)
}
}
return allRecipes.sorted(by: {
$0.name < $1.name
})
return allRecipes.sorted(by: { $0.name < $1.name })
}
/**
Asynchronously retrieves a recipe detail either from the server or locally cached data.
This function attempts to fetch a recipe detail with the specified `id` from the server using the provided `api`. If the server connection is successful, it returns the fetched recipe detail. If the server connection fails, it falls back to loading the recipe detail from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to retrieve.
Example usage:
```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 }
return nil
}
func getServer() async -> RecipeDetail? {
let (recipe, error) = await cookbookApi.getRecipe(
auth: UserSettings.shared.authString,
id: id
)
if let recipe = recipe {
do {
let recipe = try await api.getRecipe(id: id)
if save {
self.recipeDetails[id] = recipe
await self.saveLocal(recipe, path: "recipe\(id).data")
}
return recipe
} else if let error = error {
print(error)
} catch {
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
return nil
}
return nil
}
switch fetchMode {
case .preferLocal:
if let recipe = await getLocal() { return recipe }
@@ -230,31 +178,19 @@ import UIKit
}
return nil
}
/**
Asynchronously downloads and saves details, thumbnails, and full images for all recipes.
This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
await mainViewModel.downloadAllRecipes()
*/
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
await saveLocal(recipeDetail, path: "recipe\(id).data")
}
if withThumb {
let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
guard let thumbnail = thumbnail else { return }
guard let thumbnailData = thumbnail.pngData() else { return }
await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb")
}
if withImage {
let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer)
guard let image = image else { return }
@@ -262,50 +198,26 @@ import UIKit
await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full")
}
}
/// Check if recipeDetail is stored locally, either in cache or on disk
/// - Parameters
/// - recipeId: The id of a recipe.
/// - Returns: True if the recipeDetail is stored, otherwise false
func recipeDetailExists(recipeId: Int) -> Bool {
if (dataStore.recipeDetailExists(recipeId: recipeId)) {
return true
}
return false
return dataStore.recipeDetailExists(recipeId: recipeId)
}
/**
Asynchronously retrieves and returns an image for a recipe with the specified ID and size.
This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage.
// MARK: - Images
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe associated with the image.
- size: The size of the desired image (thumbnail or full).
- needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage.
Example usage:
```swift
let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true)
*/
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
func getLocal() async -> UIImage? {
return await imageFromStore(id: id, size: size)
}
func getServer() async -> UIImage? {
let (image, _) = await cookbookApi.getImage(
auth: UserSettings.shared.authString,
id: id,
size: size
)
if let image = image { return image }
return nil
do {
return try await api.getImage(id: id, size: size)
} catch {
return nil
}
}
switch fetchMode {
case .preferLocal:
if let image = imageFromCache(id: id, size: size) {
@@ -355,28 +267,20 @@ import UIKit
imagesNeedUpdate[id] = [size.rawValue: false]
return nil
}
/**
Asynchronously retrieves and returns a list of keywords (tags).
This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage.
// MARK: - Keywords
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
let keywords = await mainViewModel.getKeywords()
*/
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
func getLocal() async -> [RecipeKeyword]? {
return await loadLocal(path: "keywords.data")
}
func getServer() async -> [RecipeKeyword]? {
let (tags, _) = await cookbookApi.getTags(
auth: UserSettings.shared.authString
)
return tags
do {
return try await api.getTags()
} catch {
return nil
}
}
switch fetchMode {
@@ -399,7 +303,9 @@ import UIKit
}
return []
}
// MARK: - Data management
func deleteAllData() {
if dataStore.clearAll() {
self.categories = []
@@ -409,31 +315,14 @@ import UIKit
self.imagesNeedUpdate = [:]
}
}
/**
Asynchronously deletes a recipe with the specified ID from the server and local storage.
This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to delete.
- categoryName: The name of the category to which the recipe belongs.
Example usage:
```swift
let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts")
*/
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
let (error) = await cookbookApi.deleteRecipe(
auth: UserSettings.shared.authString,
id: id
)
if let error = error {
do {
try await api.deleteRecipe(id: id)
} catch {
return .REQUEST_DROPPED
}
let path = "recipe\(id).data"
dataStore.delete(path: path)
if recipes[categoryName] != nil {
@@ -444,95 +333,59 @@ import UIKit
}
return nil
}
/**
Asynchronously checks the server connection by attempting to fetch categories.
This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
let isConnected = await mainViewModel.checkServerConnection()
*/
func checkServerConnection() async -> Bool {
let (categories, _) = await cookbookApi.getCategories(
auth: UserSettings.shared.authString
)
if let categories = categories {
do {
let categories = try await api.getCategories()
self.categories = categories
await saveLocal(categories, path: "categories.data")
return true
} catch {
return false
}
return false
}
/**
Asynchronously uploads a recipe to the server.
This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- recipeDetail: The detailed information of the recipe to upload.
- createNew: If true, creates a new recipe on the server; otherwise, updates an existing one.
Example usage:
```swift
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
*/
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? {
var error: NetworkError? = nil
if createNew {
error = await cookbookApi.createRecipe(
auth: UserSettings.shared.authString,
recipe: recipeDetail
)
} else {
error = await cookbookApi.updateRecipe(
auth: UserSettings.shared.authString,
recipe: recipeDetail
)
}
if error != nil {
return .REQUEST_DROPPED
}
return nil
}
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await cookbookApi.importRecipe(
auth: UserSettings.shared.authString,
data: data
)
if error != nil {
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
do {
if createNew {
let id = try await api.createRecipe(recipeDetail)
return (id, nil)
} else {
let id = try await api.updateRecipe(recipeDetail)
return (id, nil)
}
} catch {
return (nil, .REQUEST_DROPPED)
}
}
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
do {
let recipeDetail = try await api.importRecipe(url: url)
return (recipeDetail, nil)
} catch {
return (nil, .REQUEST_DROPPED)
}
return (recipeDetail, nil)
}
}
// MARK: - Local storage helpers
extension AppState {
func loadLocal<T: Codable>(path: String) async -> T? {
do {
return try await dataStore.load(fromPath: path)
} catch (let error) {
print(error)
} catch {
Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
return nil
}
}
func saveLocal<T: Codable>(_ object: T, path: String) async {
await dataStore.save(data: object, toPath: path)
}
private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? {
do {
let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")"
@@ -542,18 +395,18 @@ extension AppState {
return image
}
} catch {
print("Could not find image in local storage.")
Logger.data.debug("Could not find image in local storage.")
return nil
}
return nil
}
private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async {
if let data = image.pngData() {
await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)")
}
}
private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) {
if recipeImages[id] != nil {
recipeImages[id]![size.rawValue] = image
@@ -566,14 +419,14 @@ extension AppState {
imagesNeedUpdate[id] = [size.rawValue: false]
}
}
private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? {
if recipeImages[id] != nil {
return recipeImages[id]![size.rawValue]
}
return nil
}
private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool {
if imagesNeedUpdate[id] != nil {
if imagesNeedUpdate[id]![size.rawValue] != nil {
@@ -582,26 +435,22 @@ extension AppState {
}
return true
}
private func needsUpdate(category: String, lastModified: String) -> Bool {
print("=======================")
print("original date string: \(lastModified)")
// Create a DateFormatter
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
// Convert the string to a Date object
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
if date < lastUpdate {
print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
Logger.data.debug("No update needed for \(category)")
return false
} else {
print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
Logger.data.debug("Update needed for \(category)")
return true
}
}
print("String is not a date. Update needed.")
Logger.data.debug("Date parse failed, update needed for \(category)")
return true
}
}
@@ -624,11 +473,11 @@ extension AppState {
timers[recipeId] = timer
return timer
}
func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration)
}
func deleteTimer(forRecipe recipeId: String) {
timers.removeValue(forKey: recipeId)
}