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>
497 lines
19 KiB
Swift
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
|
|
}
|
|
}
|
|
|
|
|