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>
256 lines
9.4 KiB
Swift
256 lines
9.4 KiB
Swift
//
|
|
// RecipeIngredientSection.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 01.03.24.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - RecipeView Ingredients Section
|
|
|
|
struct RecipeIngredientSection: View {
|
|
@EnvironmentObject var groceryList: GroceryListManager
|
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
HStack {
|
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "person.2")
|
|
.foregroundStyle(.secondary)
|
|
.bold()
|
|
|
|
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
|
|
}
|
|
|
|
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
|
|
IngredientListItem(
|
|
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
|
|
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
|
|
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
|
|
recipeId: viewModel.observableRecipeDetail.id,
|
|
recipeName: viewModel.observableRecipeDetail.name
|
|
)
|
|
.padding(4)
|
|
}
|
|
|
|
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
|
|
HStack() {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.secondary)
|
|
Text("Marked ingredients could not be adjusted!")
|
|
.foregroundStyle(.secondary)
|
|
}.padding(.top)
|
|
}
|
|
|
|
Button {
|
|
withAnimation {
|
|
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
|
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
|
|
} else {
|
|
groceryList.addItems(
|
|
viewModel.observableRecipeDetail.recipeIngredient,
|
|
toRecipe: viewModel.observableRecipeDetail.id,
|
|
recipeName: viewModel.observableRecipeDetail.name
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
Label(
|
|
groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "Remove all from Grocery List" : "Add All to Grocery List",
|
|
systemImage: groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "cart.badge.minus" : "cart.badge.plus"
|
|
)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.foregroundStyle(groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill((groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green).opacity(0.1))
|
|
)
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
.padding()
|
|
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
|
|
}
|
|
}
|
|
|
|
// MARK: - Recipe Edit Ingredient Section (Form-based)
|
|
|
|
struct RecipeEditIngredientSection: View {
|
|
@Binding var ingredients: [String]
|
|
|
|
var body: some View {
|
|
Section {
|
|
ForEach(ingredients.indices, id: \.self) { index in
|
|
HStack {
|
|
TextField("Ingredient", text: $ingredients[index])
|
|
Image(systemName: "line.3.horizontal")
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.onDelete { indexSet in
|
|
ingredients.remove(atOffsets: indexSet)
|
|
}
|
|
.onMove { from, to in
|
|
ingredients.move(fromOffsets: from, toOffset: to)
|
|
}
|
|
|
|
Button {
|
|
ingredients.append("")
|
|
} label: {
|
|
Label("Add Ingredient", systemImage: "plus.circle.fill")
|
|
}
|
|
} header: {
|
|
Text("Ingredients")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - RecipeIngredientSection List Item
|
|
|
|
fileprivate struct IngredientListItem: View {
|
|
@EnvironmentObject var groceryList: GroceryListManager
|
|
@Binding var ingredient: String
|
|
@Binding var servings: Double
|
|
@State var recipeYield: Double
|
|
@State var recipeId: String
|
|
var recipeName: String
|
|
|
|
@State var modifiedIngredient: AttributedString = ""
|
|
var unmodified: Bool {
|
|
servings == Double(recipeYield) || servings == 0
|
|
}
|
|
|
|
// Swipe state
|
|
@State private var dragOffset: CGFloat = 0
|
|
@State private var animationStartOffset: CGFloat = 0
|
|
private let maxDragDistance: CGFloat = 80
|
|
private let swipeThreshold: CGFloat = 0.4
|
|
|
|
private var isInGroceryList: Bool {
|
|
groceryList.containsItem(at: recipeId, item: ingredient)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .leading) {
|
|
// Swipe background
|
|
if dragOffset > 0 {
|
|
Image(systemName: isInGroceryList ? "cart.badge.minus" : "cart.badge.plus")
|
|
.font(.caption)
|
|
.bold()
|
|
.foregroundStyle(.white)
|
|
.frame(width: dragOffset, alignment: .center)
|
|
.frame(maxHeight: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(isInGroceryList ? Color.red : Color.green)
|
|
)
|
|
}
|
|
|
|
// Ingredient row
|
|
HStack(alignment: .center) {
|
|
Text("•")
|
|
.foregroundStyle(.secondary)
|
|
if !unmodified && String(modifiedIngredient.characters) == ingredient {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
if unmodified {
|
|
Text(ingredient)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(5)
|
|
} else {
|
|
Text(modifiedIngredient)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(5)
|
|
}
|
|
if isInGroceryList {
|
|
Image(systemName: "cart")
|
|
.font(.caption2)
|
|
.foregroundStyle(.green)
|
|
}
|
|
Spacer()
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.offset(x: dragOffset)
|
|
}
|
|
.clipped()
|
|
.onChange(of: servings) { newServings in
|
|
if recipeYield == 0 {
|
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
|
} else {
|
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
|
}
|
|
}
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { gesture in
|
|
if animationStartOffset == 0 {
|
|
animationStartOffset = gesture.translation.width
|
|
}
|
|
let dragAmount = gesture.translation.width
|
|
let offset = min(dragAmount, maxDragDistance + pow(max(0, dragAmount - maxDragDistance), 0.7)) - animationStartOffset
|
|
self.dragOffset = max(0, offset)
|
|
}
|
|
.onEnded { _ in
|
|
withAnimation {
|
|
if dragOffset > maxDragDistance * swipeThreshold {
|
|
if isInGroceryList {
|
|
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
|
} else {
|
|
groceryList.addItem(
|
|
ingredient,
|
|
toRecipe: recipeId,
|
|
recipeName: recipeName
|
|
)
|
|
}
|
|
}
|
|
self.dragOffset = 0
|
|
self.animationStartOffset = 0
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
struct ServingPickerView: View {
|
|
@Binding var selectedServingSize: Double
|
|
|
|
// Computed property to handle the text field input and update the selectedServingSize
|
|
var body: some View {
|
|
HStack {
|
|
Button {
|
|
selectedServingSize -= 1
|
|
} label: {
|
|
Image(systemName: "minus.square.fill")
|
|
.bold()
|
|
}
|
|
TextField("", value: $selectedServingSize, formatter: numberFormatter)
|
|
.keyboardType(.numbersAndPunctuation)
|
|
.lineLimit(1)
|
|
.multilineTextAlignment(.center)
|
|
.frame(width: 40)
|
|
Button {
|
|
selectedServingSize += 1
|
|
} label: {
|
|
Image(systemName: "plus.square.fill")
|
|
.bold()
|
|
}
|
|
}
|
|
.onChange(of: selectedServingSize) { newValue in
|
|
if newValue < 0 { selectedServingSize = 0 }
|
|
else if newValue > 100 { selectedServingSize = 100 }
|
|
}
|
|
}
|
|
}
|