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>
This commit is contained in:
2026-02-15 03:44:00 +01:00
parent 1536174586
commit 501434bd0e
2 changed files with 138 additions and 76 deletions

View File

@@ -516,6 +516,28 @@
} }
} }
}, },
"Add All to Grocery List" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alle zur Einkaufsliste hinzufügen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añadir todo a la lista de compras"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tout ajouter à la liste de courses"
}
}
}
},
"Add cooking steps for fellow chefs to follow." : { "Add cooking steps for fellow chefs to follow." : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3665,6 +3687,28 @@
} }
} }
}, },
"Remove from Grocery List" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Von der Einkaufsliste entfernen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Eliminar de la lista de compras"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Retirer de la liste de courses"
}
}
}
},
"Same as Device" : { "Same as Device" : {
"localizations" : { "localizations" : {
"de" : { "de" : {

View File

@@ -13,53 +13,32 @@ import SwiftUI
struct RecipeIngredientSection: View { struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var groceryList: GroceryListManager
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
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: {
Image(systemName: "storefront")
}.disabled(viewModel.editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients")) SecondaryLabel(text: LocalizedStringKey("Ingredients"))
Spacer() Spacer()
Image(systemName: "person.2") Image(systemName: "person.2")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.bold() .bold()
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier) ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
} }
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem( IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix], ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $viewModel.observableRecipeDetail.ingredientMultiplier, servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield), recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id recipeId: viewModel.observableRecipeDetail.id,
) { recipeName: viewModel.observableRecipeDetail.name
groceryList.addItem( )
viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4) .padding(4)
} }
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) { if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
HStack() { HStack() {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
@@ -68,7 +47,29 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}.padding(.top) }.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() .padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier) .animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
@@ -115,45 +116,67 @@ fileprivate struct IngredientListItem: View {
@Binding var servings: Double @Binding var servings: Double
@State var recipeYield: Double @State var recipeYield: Double
@State var recipeId: String @State var recipeId: String
let addToGroceryListAction: () -> Void var recipeName: String
@State var modifiedIngredient: AttributedString = "" @State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false
var unmodified: Bool { var unmodified: Bool {
servings == Double(recipeYield) || servings == 0 servings == Double(recipeYield) || servings == 0
} }
// Drag animation // Swipe state
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
@State private var animationStartOffset: CGFloat = 0 @State private var animationStartOffset: CGFloat = 0
let maxDragDistance = 50.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 { var body: some View {
HStack(alignment: .top) { ZStack(alignment: .leading) {
if groceryList.containsItem(at: recipeId, item: ingredient) { // Swipe background
Image(systemName: "storefront") if dragOffset > 0 {
.foregroundStyle(Color.green) Image(systemName: isInGroceryList ? "cart.badge.minus" : "cart.badge.plus")
} else if isSelected { .font(.caption)
Image(systemName: "checkmark.circle") .bold()
} else { .foregroundStyle(.white)
Image(systemName: "circle") .frame(width: dragOffset, alignment: .center)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isInGroceryList ? Color.red : Color.green)
)
} }
if !unmodified && String(modifiedIngredient.characters) == ingredient {
Image(systemName: "exclamationmark.triangle.fill") // Ingredient row
.foregroundStyle(.red) 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()
} }
if unmodified { .background(Color(.systemBackground))
Text(ingredient) .offset(x: dragOffset)
.multilineTextAlignment(.leading)
.lineLimit(5)
} else {
Text(modifiedIngredient)
.multilineTextAlignment(.leading)
.lineLimit(5)
//.foregroundStyle(String(modifiedIngredient.characters) == ingredient ? .red : .primary)
}
Spacer()
} }
.clipped()
.onChange(of: servings) { newServings in .onChange(of: servings) { newServings in
if recipeYield == 0 { if recipeYield == 0 {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings) modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
@@ -161,34 +184,29 @@ fileprivate struct IngredientListItem: View {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield) modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
} }
} }
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.offset(x: dragOffset, y: 0)
.animation(.easeInOut, value: isSelected)
.gesture( .gesture(
DragGesture() DragGesture()
.onChanged { gesture in .onChanged { gesture in
// Update drag offset as the user drags
if animationStartOffset == 0 { if animationStartOffset == 0 {
animationStartOffset = gesture.translation.width animationStartOffset = gesture.translation.width
} }
let dragAmount = gesture.translation.width let dragAmount = gesture.translation.width
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset let offset = min(dragAmount, maxDragDistance + pow(max(0, dragAmount - maxDragDistance), 0.7)) - animationStartOffset
self.dragOffset = max(0, offset) self.dragOffset = max(0, offset)
} }
.onEnded { gesture in .onEnded { _ in
withAnimation { withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold if dragOffset > maxDragDistance * swipeThreshold {
if groceryList.containsItem(at: recipeId, item: ingredient) { if isInGroceryList {
groceryList.deleteItem(ingredient, fromRecipe: recipeId) groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else { } else {
addToGroceryListAction() groceryList.addItem(
} ingredient,
toRecipe: recipeId,
recipeName: recipeName
)
}
} }
// Animate back to original position
self.dragOffset = 0 self.dragOffset = 0
self.animationStartOffset = 0 self.animationStartOffset = 0
} }