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
struct OnboardingView: View {
@@ -203,7 +204,7 @@ struct ServerAddressField: View {
.tint(.white)
.font(.headline)
.onChange(of: serverProtocol) { value in
print(value)
Logger.view.debug("\(value.rawValue)")
userSettings.serverProtocol = value.rawValue
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
@@ -72,7 +73,7 @@ struct TokenLoginView: View {
case .username:
focusedField = .token
default:
print("Attempting to log in ...")
Logger.view.debug("Attempting to log in ...")
}
}
}
@@ -87,21 +88,16 @@ struct TokenLoginView: View {
showAlert = true
return false
}
UserSettings.shared.setAuthString()
let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString)
if let error = error {
let client = CookbookApiFactory.makeClient()
do {
let _ = try await client.getCategories()
return true
} catch {
alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true
return false
}
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
return true
}
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
import WebKit
@@ -82,7 +83,7 @@ struct V2LoginView: View {
Task {
let error = await sendLoginV2Request()
if let error = error {
alertMessage = "A network error occured (\(error.rawValue))."
alertMessage = "A network error occured (\(error.localizedDescription))."
showAlert = true
}
if let loginRequest = loginRequest {
@@ -151,13 +152,13 @@ struct V2LoginView: View {
}
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
guard let loginRequest = loginRequest else { return (nil, .invalidRequest) }
return await NextcloudApi.loginV2Response(req: loginRequest)
}
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
if let error = error {
alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))"
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
showAlert = true
return
}
@@ -166,7 +167,7 @@ struct V2LoginView: View {
showAlert = true
return
}
print("Login successful for user \(response.loginName)!")
Logger.network.debug("Login successful for user \(response.loginName)!")
UserSettings.shared.username = response.loginName
UserSettings.shared.token = response.appPassword
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"

View File

@@ -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")

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)
}

View File

@@ -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)")
}
}
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
@@ -135,7 +136,7 @@ struct SettingsView: View {
Section {
Button("Log out") {
print("Log out.")
Logger.view.debug("Log out.")
viewModel.alertType = .LOG_OUT
viewModel.showAlert = true
@@ -143,7 +144,7 @@ struct SettingsView: View {
.tint(.red)
Button("Delete local data") {
print("Clear cache.")
Logger.view.debug("Clear cache.")
viewModel.alertType = .DELETE_CACHE
viewModel.showAlert = true
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
@@ -150,7 +151,7 @@ class GroceryRecipeItem: Identifiable, Codable {
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))")
Logger.view.debug("Adding item of recipe \(String(describing: recipeName))")
DispatchQueue.main.async {
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
@@ -174,7 +175,7 @@ class GroceryRecipeItem: Identifiable, Codable {
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
print("Deleting item \(itemName)")
Logger.view.debug("Deleting item \(itemName)")
guard let recipe = groceryDict[recipeId] else { return }
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex)
@@ -186,20 +187,20 @@ class GroceryRecipeItem: Identifiable, Codable {
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
objectWillChange.send()
}
func deleteAll() {
print("Deleting all grocery items")
Logger.view.debug("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
Logger.view.debug("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
}
@@ -229,7 +230,7 @@ class GroceryRecipeItem: Identifiable, Codable {
) else { return }
self.groceryDict = groceryDict
} catch {
print("Unable to load grocery list")
Logger.view.error("Unable to load grocery list")
}
}
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import OSLog
import SwiftUI
@@ -143,7 +144,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Server connection indicator
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Check server connection")
Logger.view.debug("Check server connection")
viewModel.presentConnectionPopover = true
} label: {
if viewModel.presentLoadingIndicator {
@@ -170,7 +171,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Create new recipes
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Add new recipe")
Logger.view.debug("Add new recipe")
viewModel.presentEditView = true
} label: {
Image(systemName: "plus.circle.fill")