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:
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
|
||||
|
||||
|
||||
struct RecipeListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@@ -64,7 +65,6 @@ struct RecipeListView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
print("Add new recipe")
|
||||
showEditView = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import AVFoundation
|
||||
@@ -152,7 +153,7 @@ extension RecipeTimer {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("Failed to set audio session category. Error: \(error)")
|
||||
Logger.view.error("Failed to set audio session category. Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ extension RecipeTimer {
|
||||
audioPlayer?.prepareToPlay()
|
||||
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||
} catch {
|
||||
print("Error loading sound file: \(error)")
|
||||
Logger.view.error("Error loading sound file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,9 +186,9 @@ extension RecipeTimer {
|
||||
func requestNotificationPermissions() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
print("Notification permission granted.")
|
||||
Logger.view.debug("Notification permission granted.")
|
||||
} else if let error = error {
|
||||
print("Notification permission denied because: \(error.localizedDescription).")
|
||||
Logger.view.error("Notification permission denied because: \(error.localizedDescription).")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +205,7 @@ extension RecipeTimer {
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error scheduling notification: \(error)")
|
||||
Logger.view.error("Error scheduling notification: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user