Stop cascading syncs by adding an isReconciling flag so that reconcileFromServer no longer triggers scheduleSync via addItem/deleteItem. Make Reminders write-only by removing the diff/sync logic from the onDataChanged callback. Fetch fresh server state in RecipeView reconcile instead of using stale local cache. Track pending removal recipe IDs via DataStore so performInitialSync can push deletions for recipes whose grocery keys have already been removed from groceryDict. Fix a race condition in RemindersGroceryStore where EKEventStoreChanged notifications triggered load() before saveMappings() finished writing to disk, causing the correct in-memory state to be overwritten with stale data. Add ignoreNextExternalChange flag to skip self-triggered reloads. Restyle the add/remove all grocery button to match the Plan recipe button style using Label, subheadline font, and rounded rectangle background. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
548 lines
21 KiB
Swift
548 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 {
|
|
let serverRecipe = await appState.getRecipe(
|
|
id: viewModel.recipe.recipe_id,
|
|
fetchMode: .onlyServer
|
|
)
|
|
groceryList.syncManager?.reconcileFromServer(
|
|
serverState: serverRecipe?.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
|
|
}
|
|
}
|
|
|
|
|