Introduces weekly meal planning with a calendar-based tab view, per-recipe date assignments synced via Nextcloud Cookbook custom metadata, and 30-day automatic pruning of old entries on load, save, and sync merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
544 lines
21 KiB
Swift
544 lines
21 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
|
|
@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
|
|
}
|
|
}
|
|
|
|
|