Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
Hendrik Hogertz c8ddb098d1 Redesign search tab, add category cards, recent recipes, and complete German translations
Overhaul SearchTabView with search history, empty/no-results states, and dynamic
navigation title. Extract CategoryCardView and RecentRecipesSection into standalone
views. Update RecipeTabView, RecipeListView, RecipeCardView, and MainView for the
modernized UI. Add all 12 missing German translations in Localizable.xcstrings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 01:47:16 +01:00

497 lines
19 KiB
Swift

//
// 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
@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 {
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) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe 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 != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
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)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(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.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.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
)
} else {
// Prepare view for a new recipe
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: - 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 importUrl: String = ""
@Published var presentShareSheet: Bool = false
@Published var presentInstructionEditView: Bool = false
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
var newRecipe: Bool = false
// 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
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
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
}
return error
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
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")
}
} 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)
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
}
}