Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
2025-05-31 11:12:14 +02:00

880 lines
33 KiB
Swift

//
// RecipeDetailView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct RecipeView: View {
@Bindable var recipe: Recipe
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let recipeImage = recipe.image, let image = recipeImage.image {
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 = recipe.image, let image = recipeImage.image {
Image(uiImage: image)
.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)
//RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $recipe.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 recipe.recipeDescription != "" || viewModel.editMode {
EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!recipe.ingredients.isEmpty || viewModel.editMode) {
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
}
if(!recipe.instructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
}
if(!recipe.tools.isEmpty || viewModel.editMode) {
RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
}
RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
//RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(recipe: recipe)
}
}
}
.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)
.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: $recipe.instructions,
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: $recipe.ingredients,
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: $recipe.tools,
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
) ?? CookbookApiRecipeDetailV1.error
viewModel.setupView(recipeDetail: recipeDetail)
// 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: CookbookApiRecipeDetailV1())
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 recipe: Recipe
@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 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()
}
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)
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
}
}
*/
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@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 {
print("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 {
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
}
} else {
print("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) {
viewModel.presentAlert(alert)
return
}
}
await appState.getCategories()
await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer)
if let id = Int(viewModel.observableRecipeDetail.id) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
}
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.recipe.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
}
}
/*
struct RecipeView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State 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.recipe.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.recipe.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipe.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.recipe.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.recipe.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.recipe.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.recipe.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.recipe.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.recipe.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.recipe.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if let recipeStub = viewModel.recipeStub {
// For existing recipes, load the recipeDetail and image
let recipe = await cookbookState.selectedAccountState.getRecipe(
id: recipeStub.id
) ?? Recipe()
viewModel.recipe = recipe
// Show download badge
/*if viewModel.recipeStub!.storedLocally == nil {
viewModel.recipeStub?.storedLocally = cookbookState.selectedAccountState.recipeDetailExists(
recipeId: viewModel.recipe.recipe_id
)
}
viewModel.isDownloaded = viewModel.recipeStub!.storedLocally
*/
// Load recipe image
viewModel.recipeImage = await cookbookState.selectedAccountState.getImage(
id: recipeStub.id,
size: .FULL
)
} else {
// Prepare view for a new recipe
viewModel.editMode = true
viewModel.isDownloaded = false
viewModel.recipe = Recipe()
}
}
.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 && cookbookState.selectedAccountState.keywords.isEmpty {
Task {
if let keywords = await cookbookState.selectedAccountState.getTags()?.sorted(by: { a, b in
a.recipe_count > b.recipe_count
}) {
cookbookState.selectedAccountState.keywords = keywords
}
}
}
}
}
// MARK: - RecipeView ViewModel
@Observable class ViewModel {
var recipeImage: UIImage? = nil
var editMode: Bool = false
var showTitle: Bool = false
var isDownloaded: Bool? = nil
var importUrl: String = ""
var presentShareSheet: Bool = false
var presentInstructionEditView: Bool = false
var presentIngredientEditView: Bool = false
var presentToolEditView: Bool = false
var recipeStub: RecipeStub? = nil
var recipe: Recipe = Recipe()
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipeStub: RecipeStub) {
self.recipeStub = recipeStub
}
init() {
self.newRecipe = true
}
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)
return nil
}*/
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.recipe = scrapedRecipe.toRecipe()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State 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 {
print("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 {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await cookbookState.selectedAccountState.postRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
if let alert = await cookbookState.selectedAccountState.updateRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: viewModel.recipe.recipeCategory)
let _ = await cookbookState.selectedAccountState.getRecipe(id: viewModel.recipe.id)
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.recipe.recipeCategory
if let alert = await cookbookState.selectedAccountState.deleteRecipe(id: viewModel.recipe.id) {
viewModel.presentAlert(alert)
return
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: category)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in cookbookState.selectedAccountState.recipeStubs.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}
*/