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
@@ -291,22 +292,17 @@ extension RecipeView {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
// Fetch the image from the server if the import created a recipe with a valid id
if let recipeId = Int(scrapedRecipe.id), recipeId > 0 {
viewModel.recipeImage = await appState.getImage(
id: recipeId,
size: .FULL,
fetchMode: .onlyServer
)
}
return nil
}
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
return error
}
}
@@ -371,7 +367,7 @@ struct RecipeViewToolBar: ToolbarContent {
}
Button {
print("Sharing recipe ...")
Logger.view.debug("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
@@ -386,34 +382,70 @@ struct RecipeViewToolBar: ToolbarContent {
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) {
viewModel.presentAlert(alert)
return
// Check if the recipe was already created on the server by import
let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0
let alreadyCreatedByImport = importedId > 0
if alreadyCreatedByImport {
Logger.view.debug("Uploading changes to imported recipe (id: \(importedId)).")
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
if let alert {
viewModel.presentAlert(alert)
return
}
} else {
Logger.view.debug("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
if let alert {
viewModel.presentAlert(alert)
return
}
if let newId {
viewModel.observableRecipeDetail.id = String(newId)
}
}
} else {
print("Uploading changed recipe.")
Logger.view.debug("Uploading changed recipe.")
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return
}
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) {
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
if let alert {
viewModel.presentAlert(alert)
return
}
}
await appState.getCategories()
await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer)
let newCategory = viewModel.observableRecipeDetail.recipeCategory.isEmpty ? "*" : viewModel.observableRecipeDetail.recipeCategory
let oldCategory = viewModel.recipeDetail.recipeCategory.isEmpty ? "*" : viewModel.recipeDetail.recipeCategory
await appState.getCategory(named: newCategory, fetchMode: .preferServer)
if oldCategory != newCategory {
await appState.getCategory(named: oldCategory, fetchMode: .preferServer)
}
if let id = Int(viewModel.observableRecipeDetail.id) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
// Fetch the image after upload so it displays in view mode
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
// Update recipe reference so the view tracks the server-assigned id
viewModel.recipe = Recipe(
name: viewModel.observableRecipeDetail.name,
keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
imageUrl: viewModel.observableRecipeDetail.imageUrl,
imagePlaceholderUrl: "",
recipe_id: id
)
}
viewModel.newRecipe = false
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}