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:
@@ -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" : {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user