Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift
Hendrik Hogertz c38d4075be Fix grocery sync deletions not persisting and Reminders race condition
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>
2026-02-15 06:04:41 +01:00

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 }
}
}
}