Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift
Hendrik Hogertz 501434bd0e Redesign ingredient list with swipe-to-add grocery action and add-all button
Remove checkbox icons and tap-to-select state from ingredient items. Replace
with bullet points and a small green cart indicator for items already in the
grocery list. Add a full-width "Add All to Grocery List" / "Remove from
Grocery List" button below ingredients. Swipe right on individual ingredients
shows a rounded green/red cart icon to add/remove single items.

Add DE/ES/FR translations for new grocery list button strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 03:44:00 +01:00

250 lines
9.1 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: {
HStack {
Image(systemName: groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "cart.badge.minus" : "cart.badge.plus")
.foregroundStyle(groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? .red : .green)
Text(groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "Remove from Grocery List" : "Add All to Grocery List")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.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 }
}
}
}