// // RecipeDetailView.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 15.09.23. // import Foundation import OSLog import SwiftUI struct RecipeView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var mealPlan: MealPlanManager @Environment(\.dismiss) private var dismiss @StateObject var viewModel: ViewModel @GestureState private var dragOffset = CGSize.zero var imageHeight: CGFloat { if let image = viewModel.recipeImage { return image.size.height < 350 ? image.size.height : 350 } return 200 } private enum CoordinateSpaces { case scrollView } var body: some View { Group { if viewModel.editMode { recipeEditForm } else { recipeViewContent } } .navigationBarTitleDisplayMode(.inline) .toolbar(.visible, for: .navigationBar) .navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : "")) .toolbar { RecipeViewToolBar(viewModel: viewModel) } .sheet(isPresented: $viewModel.presentShareSheet) { ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), recipeImage: viewModel.recipeImage, presentShareSheet: $viewModel.presentShareSheet) } .sheet(isPresented: $viewModel.presentKeywordSheet) { KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) } .sheet(isPresented: $viewModel.presentMealPlanSheet) { AddToMealPlanSheet( recipeId: String(viewModel.recipe.recipe_id), recipeName: viewModel.observableRecipeDetail.name, prepTime: viewModel.recipeDetail.prepTime, recipeImage: viewModel.recipeImage ) .environmentObject(mealPlan) } .task { // Load recipe detail if !viewModel.newRecipe { // For existing recipes, load the recipeDetail and image let recipeDetail = await appState.getRecipe( id: viewModel.recipe.recipe_id, fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer ) ?? RecipeDetail.error viewModel.setupView(recipeDetail: recipeDetail) // Track as recently viewed appState.addToRecentRecipes(viewModel.recipe) // Show download badge if viewModel.recipe.storedLocally == nil { viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id) } viewModel.isDownloaded = viewModel.recipe.storedLocally // Load recipe image viewModel.recipeImage = await appState.getImage( id: viewModel.recipe.recipe_id, size: .FULL, fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer ) // Reconcile server grocery state with local data if UserSettings.shared.grocerySyncEnabled { groceryList.syncManager?.reconcileFromServer( serverState: viewModel.recipeDetail.groceryState, recipeId: String(viewModel.recipe.recipe_id), recipeName: viewModel.recipeDetail.name ) } // Reconcile server meal plan state with local data if UserSettings.shared.mealPlanSyncEnabled { mealPlan.syncManager?.reconcileFromServer( serverAssignment: viewModel.recipeDetail.mealPlanAssignment, recipeId: String(viewModel.recipe.recipe_id), recipeName: viewModel.recipeDetail.name ) } } else { // Prepare view for a new recipe if let preloaded = viewModel.preloadedRecipeDetail { viewModel.setupView(recipeDetail: preloaded) viewModel.preloadedRecipeDetail = nil // Load image if the import created a recipe with a valid id if let recipeId = Int(preloaded.id), recipeId > 0 { viewModel.recipeImage = await appState.getImage( id: recipeId, size: .FULL, fetchMode: .onlyServer ) } } else { viewModel.setupView(recipeDetail: RecipeDetail()) } viewModel.editMode = true viewModel.isDownloaded = false } } .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { ForEach(viewModel.alertType.alertButtons) { buttonType in if buttonType == .OK { Button(AlertButton.OK.rawValue, role: .cancel) { Task { await viewModel.alertAction() } } } else if buttonType == .CANCEL { Button(AlertButton.CANCEL.rawValue, role: .cancel) { } } else if buttonType == .DELETE { Button(AlertButton.DELETE.rawValue, role: .destructive) { Task { await viewModel.alertAction() } } } } } message: { Text(viewModel.alertType.localizedDescription) } .onAppear { if UserSettings.shared.keepScreenAwake { UIApplication.shared.isIdleTimerDisabled = true } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false } .onChange(of: viewModel.editMode) { newValue in if newValue && appState.allKeywords.isEmpty { Task { appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in a.recipe_count > b.recipe_count }) } } } } // MARK: - View Mode private var recipeViewContent: some View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { ParallaxHeader( coordinateSpace: CoordinateSpaces.scrollView, defaultHeight: imageHeight ) { if let recipeImage = viewModel.recipeImage { Image(uiImage: recipeImage) .resizable() .scaledToFill() .frame(maxHeight: imageHeight + 200) .clipped() } else { Rectangle() .frame(height: 400) .foregroundStyle( LinearGradient( gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) } } VStack(alignment: .leading) { HStack { Text(viewModel.observableRecipeDetail.name) .font(.title) .bold() Spacer() if let isDownloaded = viewModel.isDownloaded { Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down") .foregroundColor(.secondary) } }.padding([.top, .horizontal]) if viewModel.observableRecipeDetail.description != "" { Text(viewModel.observableRecipeDetail.description) .fontWeight(.medium) .padding(.horizontal) .padding(.top, 2) } RecipeDurationSection(viewModel: viewModel) Button { viewModel.presentMealPlanSheet = true } label: { Label("Plan recipe", systemImage: "calendar.badge.plus") .font(.subheadline) .fontWeight(.medium) .frame(maxWidth: .infinity) .padding(.vertical, 10) .foregroundStyle(Color.nextcloudBlue) .background( RoundedRectangle(cornerRadius: 10) .fill(Color.nextcloudBlue.opacity(0.1)) ) } .padding(.horizontal) Divider() LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { if !viewModel.observableRecipeDetail.recipeIngredient.isEmpty { RecipeIngredientSection(viewModel: viewModel) } if !viewModel.observableRecipeDetail.recipeInstructions.isEmpty { RecipeInstructionSection(viewModel: viewModel) } if !viewModel.observableRecipeDetail.tool.isEmpty { RecipeToolSection(viewModel: viewModel) } RecipeNutritionSection(viewModel: viewModel) } Divider() LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { RecipeKeywordSection(viewModel: viewModel) MoreInformationSection(viewModel: viewModel) } } .padding(.horizontal, 5) .background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20))) } } .coordinateSpace(name: CoordinateSpaces.scrollView) .ignoresSafeArea(.container, edges: .top) } // MARK: - Edit Mode Form private var recipeEditForm: some View { Form { if let recipeImage = viewModel.recipeImage { Section { Image(uiImage: recipeImage) .resizable() .scaledToFill() .frame(maxHeight: 200) .clipped() .listRowInsets(EdgeInsets()) } } Section { TextField("Recipe Name", text: $viewModel.observableRecipeDetail.name) .font(.headline) TextField("Description", text: $viewModel.observableRecipeDetail.description, axis: .vertical) .lineLimit(1...5) } RecipeEditMetadataSection(viewModel: viewModel) .environmentObject(appState) RecipeEditDurationSection( prepTime: viewModel.observableRecipeDetail.prepTime, cookTime: viewModel.observableRecipeDetail.cookTime, totalTime: viewModel.observableRecipeDetail.totalTime ) RecipeEditIngredientSection(ingredients: $viewModel.observableRecipeDetail.recipeIngredient) RecipeEditInstructionSection(instructions: $viewModel.observableRecipeDetail.recipeInstructions) RecipeEditToolSection(tools: $viewModel.observableRecipeDetail.tool) RecipeEditNutritionSection(nutrition: $viewModel.observableRecipeDetail.nutrition) } } // MARK: - RecipeView ViewModel class ViewModel: ObservableObject { @Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail() @Published var recipeDetail: RecipeDetail = RecipeDetail.error @Published var recipeImage: UIImage? = nil @Published var editMode: Bool = false @Published var showTitle: Bool = false @Published var isDownloaded: Bool? = nil @Published var presentShareSheet: Bool = false @Published var presentKeywordSheet: Bool = false @Published var presentMealPlanSheet: Bool = false var recipe: Recipe var sharedURL: URL? = nil var newRecipe: Bool = false var preloadedRecipeDetail: RecipeDetail? = nil // Alerts @Published var presentAlert = false var alertType: UserAlert = RecipeAlert.GENERIC var alertAction: () async -> () = { } // Initializers init(recipe: Recipe) { self.recipe = recipe } init() { self.newRecipe = true self.recipe = Recipe( name: String(localized: "New Recipe"), keywords: "", dateCreated: "", dateModified: "", imageUrl: "", imagePlaceholderUrl: "", recipe_id: 0) } // View setup func setupView(recipeDetail: RecipeDetail) { self.recipeDetail = recipeDetail self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail) } func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) { alertType = type alertAction = action presentAlert = true } } } // MARK: - Tool Bar struct RecipeViewToolBar: ToolbarContent { @EnvironmentObject var appState: AppState @EnvironmentObject var mealPlan: MealPlanManager @Environment(\.dismiss) private var dismiss @ObservedObject var viewModel: RecipeView.ViewModel var body: some ToolbarContent { if viewModel.editMode { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { viewModel.editMode = false if viewModel.newRecipe { dismiss() } } } ToolbarItem(placement: .topBarTrailing) { Button { Task { await handleUpload() } } label: { if viewModel.newRecipe { Text("Upload Recipe") } else { Text("Upload Changes") } } } } else { ToolbarItem(placement: .topBarTrailing) { Menu { Button { viewModel.editMode = true } label: { Label("Edit", systemImage: "pencil") } Button { Logger.view.debug("Sharing recipe ...") viewModel.presentShareSheet = true } label: { Label("Share Recipe", systemImage: "square.and.arrow.up") } Divider() Button(role: .destructive) { viewModel.presentAlert( RecipeAlert.CONFIRM_DELETE, action: { await handleDelete() } ) } label: { Label("Delete Recipe", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle") } } } } func handleUpload() async { if viewModel.newRecipe { // 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 { Logger.view.debug("Uploading changed recipe.") guard let _ = Int(viewModel.observableRecipeDetail.id) else { viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) return } let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) if let alert { viewModel.presentAlert(alert) return } } await appState.getCategories() 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) } func handleDelete() async { let category = viewModel.observableRecipeDetail.recipeCategory guard let id = Int(viewModel.observableRecipeDetail.id) else { viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) return } if let alert = await appState.deleteRecipe(withId: id, categoryName: viewModel.observableRecipeDetail.recipeCategory) { viewModel.presentAlert(alert) return } await appState.getCategories() await appState.getCategory(named: category, fetchMode: .preferServer) mealPlan.removeAllAssignments(forRecipeId: String(id)) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) dismiss() } func recipeValid() -> RecipeAlert? { // Check if the recipe has a name if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" { return RecipeAlert.NO_TITLE } // Check if the recipe has a unique name for recipeList in appState.recipes.values { for r in recipeList { if r.name .replacingOccurrences(of: " ", with: "") .lowercased() == viewModel.observableRecipeDetail.name .replacingOccurrences(of: " ", with: "") .lowercased() { return RecipeAlert.DUPLICATE } } } return nil } }