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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user