Adjustable recipe ingredient amount
This commit is contained in:
Binary file not shown.
@@ -9,6 +9,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class ObservableRecipeDetail: ObservableObject {
|
class ObservableRecipeDetail: ObservableObject {
|
||||||
|
// Cookbook recipe detail fields
|
||||||
var id: String
|
var id: String
|
||||||
@Published var name: String
|
@Published var name: String
|
||||||
@Published var keywords: [String]
|
@Published var keywords: [String]
|
||||||
@@ -25,6 +26,9 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
@Published var recipeInstructions: [String]
|
@Published var recipeInstructions: [String]
|
||||||
@Published var nutrition: [String:String]
|
@Published var nutrition: [String:String]
|
||||||
|
|
||||||
|
// Additional functionality
|
||||||
|
@Published var ingredientMultiplier: Double
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
id = ""
|
id = ""
|
||||||
name = String(localized: "New Recipe")
|
name = String(localized: "New Recipe")
|
||||||
@@ -35,12 +39,14 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
totalTime = DurationComponents()
|
totalTime = DurationComponents()
|
||||||
description = ""
|
description = ""
|
||||||
url = ""
|
url = ""
|
||||||
recipeYield = 0
|
recipeYield = 1
|
||||||
recipeCategory = ""
|
recipeCategory = ""
|
||||||
tool = []
|
tool = []
|
||||||
recipeIngredient = []
|
recipeIngredient = []
|
||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
|
|
||||||
|
ingredientMultiplier = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ recipeDetail: RecipeDetail) {
|
init(_ recipeDetail: RecipeDetail) {
|
||||||
@@ -53,12 +59,14 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
|
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
|
||||||
description = recipeDetail.description
|
description = recipeDetail.description
|
||||||
url = recipeDetail.url
|
url = recipeDetail.url
|
||||||
recipeYield = recipeDetail.recipeYield
|
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
|
||||||
recipeCategory = recipeDetail.recipeCategory
|
recipeCategory = recipeDetail.recipeCategory
|
||||||
tool = recipeDetail.tool
|
tool = recipeDetail.tool
|
||||||
recipeIngredient = recipeDetail.recipeIngredient
|
recipeIngredient = recipeDetail.recipeIngredient
|
||||||
recipeInstructions = recipeDetail.recipeInstructions
|
recipeInstructions = recipeDetail.recipeInstructions
|
||||||
nutrition = recipeDetail.nutrition
|
nutrition = recipeDetail.nutrition
|
||||||
|
|
||||||
|
ingredientMultiplier = Double(recipeDetail.recipeYield)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toRecipeDetail() -> RecipeDetail {
|
func toRecipeDetail() -> RecipeDetail {
|
||||||
@@ -83,68 +91,72 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*static func modifyIngredientAmounts(in ingredient: String, withFactor factor: Double) -> String {
|
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
|
||||||
// Regular expression to match numbers, including integers and decimals
|
var matches = ObservableRecipeDetail.matchPatternAndMultiply(.mixedFraction, in: ingredient, multFactor: factor)
|
||||||
// Patterns:
|
matches.append(contentsOf: ObservableRecipeDetail.matchPatternAndMultiply(.fraction, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 })))
|
||||||
// "\\b\\d+(\\.\\d+)?\\b" works only if there is a space following
|
matches.append(contentsOf: ObservableRecipeDetail.matchPatternAndMultiply(.number, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 })))
|
||||||
let regex = try! NSRegularExpression(pattern: "\\b\\d+(\\.\\d+)?\\b", options: [])
|
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
|
||||||
let matches = regex.matches(in: ingredient, options: [], range: NSRange(ingredient.startIndex..., in: ingredient))
|
|
||||||
|
|
||||||
// Reverse the matches to replace from the end to avoid affecting indices of unprocessed matches
|
var attributedString = AttributedString(ingredient)
|
||||||
let reversedMatches = matches.reversed()
|
for (newSubstring, matchRange) in matches {
|
||||||
|
print(newSubstring, matchRange)
|
||||||
var modifiedIngredient = ingredient
|
guard let range = Range(matchRange, in: attributedString) else { continue }
|
||||||
|
var attributedSubString = AttributedString(newSubstring)
|
||||||
for match in reversedMatches {
|
attributedSubString.foregroundColor = .blue
|
||||||
guard let range = Range(match.range, in: modifiedIngredient) else { continue }
|
attributedString.replaceSubrange(range, with: attributedSubString)
|
||||||
let originalNumberString = String(modifiedIngredient[range])
|
print("\n", attributedString)
|
||||||
if let originalNumber = Double(originalNumberString) {
|
|
||||||
let modifiedNumber = originalNumber * factor
|
|
||||||
// Format the number to remove trailing zeros if it's an integer after multiplication
|
|
||||||
let formattedNumber = formatNumber(modifiedNumber)
|
|
||||||
modifiedIngredient.replaceSubrange(range, with: formattedNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return modifiedIngredient
|
return attributedString
|
||||||
}*/
|
|
||||||
static func modifyIngredientAmounts(in ingredient: String, withFactor factor: Double) -> String {
|
|
||||||
// Regular expression to match numbers (including integers and decimals) and fractions
|
|
||||||
let regexPattern = "\\b(\\d+(\\.\\d+)?)\\b|\\b(\\d+/\\d+)\\b"
|
|
||||||
let regex = try! NSRegularExpression(pattern: regexPattern, options: [])
|
|
||||||
let matches = regex.matches(in: ingredient, options: [], range: NSRange(ingredient.startIndex..., in: ingredient))
|
|
||||||
|
|
||||||
var modifiedIngredient = ingredient
|
|
||||||
|
|
||||||
// Reverse the matches to replace from the end to avoid affecting indices of unprocessed matches
|
|
||||||
let reversedMatches = matches.reversed()
|
|
||||||
|
|
||||||
for match in reversedMatches {
|
|
||||||
let fullMatchRange = match.range(at: 0)
|
|
||||||
|
|
||||||
// Check for a fractional match
|
|
||||||
if match.range(at: 3).location != NSNotFound, let fractionRange = Range(match.range(at: 3), in: modifiedIngredient) {
|
|
||||||
let fractionString = String(modifiedIngredient[fractionRange])
|
|
||||||
let fractionParts = fractionString.split(separator: "/").compactMap { Double($0) }
|
|
||||||
if fractionParts.count == 2, let numerator = fractionParts.first, let denominator = fractionParts.last, denominator != 0 {
|
|
||||||
let fractionValue = numerator / denominator
|
|
||||||
let modifiedNumber = fractionValue * factor
|
|
||||||
let formattedNumber = formatNumber(modifiedNumber)
|
|
||||||
modifiedIngredient.replaceSubrange(fractionRange, with: formattedNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check for an integer or decimal match
|
|
||||||
else if let numberRange = Range(fullMatchRange, in: modifiedIngredient) {
|
|
||||||
let numberString = String(modifiedIngredient[numberRange])
|
|
||||||
if let number = Double(numberString) {
|
|
||||||
let modifiedNumber = number * factor
|
|
||||||
let formattedNumber = formatNumber(modifiedNumber)
|
|
||||||
modifiedIngredient.replaceSubrange(numberRange, with: formattedNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return modifiedIngredient
|
static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range<String.Index>]? = nil) -> [(String, Range<String.Index>)] {
|
||||||
|
var foundMatches: [(String, Range<String.Index>)] = []
|
||||||
|
do {
|
||||||
|
let regex = try NSRegularExpression(pattern: expr.pattern)
|
||||||
|
let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str))
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let matchRange = Range(match.range, in: str) else { continue }
|
||||||
|
if let excludedRanges = excludedRanges,
|
||||||
|
excludedRanges.contains(where: { $0.overlaps(matchRange) }) {
|
||||||
|
// If there's an overlap, skip this match.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedString = String(str[matchRange])
|
||||||
|
|
||||||
|
// Process each match based on its type
|
||||||
|
var adjustedValue: Double = 0
|
||||||
|
switch expr {
|
||||||
|
case .number:
|
||||||
|
guard let number = Double(matchedString) else { continue }
|
||||||
|
adjustedValue = number
|
||||||
|
case .fraction:
|
||||||
|
let fracComponents = matchedString.split(separator: "/")
|
||||||
|
guard fracComponents.count == 2 else { continue }
|
||||||
|
guard let nominator = Double(fracComponents[0]) else { continue }
|
||||||
|
guard let denominator = Double(fracComponents[1]), denominator > 0 else { continue }
|
||||||
|
adjustedValue = nominator/denominator
|
||||||
|
case .mixedFraction:
|
||||||
|
guard match.numberOfRanges == 4 else { continue }
|
||||||
|
guard let intRange = Range(match.range(at: 1), in: str) else { continue }
|
||||||
|
guard let nomRange = Range(match.range(at: 2), in: str) else { continue }
|
||||||
|
guard let denomRange = Range(match.range(at: 3), in: str) else { continue }
|
||||||
|
guard let number = Double(str[intRange]),
|
||||||
|
let nominator = Double(str[nomRange]),
|
||||||
|
let denominator = Double(str[denomRange]), denominator > 0
|
||||||
|
else { continue }
|
||||||
|
adjustedValue = number + nominator/denominator
|
||||||
|
}
|
||||||
|
let formattedAdjustedValue = formatNumber(adjustedValue * multFactor)
|
||||||
|
foundMatches.append((formattedAdjustedValue, matchRange))
|
||||||
|
}
|
||||||
|
return foundMatches
|
||||||
|
} catch {
|
||||||
|
print("Regex error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
static func formatNumber(_ value: Double) -> String {
|
static func formatNumber(_ value: Double) -> String {
|
||||||
@@ -180,5 +192,18 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RegexPattern {
|
||||||
|
case mixedFraction, fraction, number
|
||||||
|
|
||||||
|
var pattern: String {
|
||||||
|
switch self {
|
||||||
|
case .mixedFraction:
|
||||||
|
#"(\d+)\s+(\d+)/(\d+)"#
|
||||||
|
case .fraction:
|
||||||
|
#"(?:[1-9][0-9]*|0)\/[1-9][0-9]*"#
|
||||||
|
case .number:
|
||||||
|
#"(\d+(\.\d+)?)"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1859,6 +1859,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ingredients for %lld servings" : {
|
"Ingredients for %lld servings" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1881,6 +1882,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ingredients per serving" : {
|
"Ingredients per serving" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2575,6 +2577,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Only highlighted ingredient were adjusted!" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Other" : {
|
"Other" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3131,9 +3136,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Serving Size" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Servings" : {
|
"Servings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3862,9 +3864,6 @@
|
|||||||
},
|
},
|
||||||
"tsp" : {
|
"tsp" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Unable to adjust the highlighted ingredient amount!" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Unable to complete action." : {
|
"Unable to complete action." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -13,20 +13,10 @@ import SwiftUI
|
|||||||
struct RecipeIngredientSection: View {
|
struct RecipeIngredientSection: View {
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
@State var servingsMultiplier: Double = 1
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
if viewModel.observableRecipeDetail.recipeYield == 0 {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
|
||||||
} else if viewModel.observableRecipeDetail.recipeYield == 1 {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
|
|
||||||
} else {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings"))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
ServingPickerView(selectedServingSize: $servingsMultiplier)
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
||||||
@@ -46,18 +36,30 @@ struct RecipeIngredientSection: View {
|
|||||||
Image(systemName: "heart.text.square")
|
Image(systemName: "heart.text.square")
|
||||||
}
|
}
|
||||||
}.disabled(viewModel.editMode)
|
}.disabled(viewModel.editMode)
|
||||||
|
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "person.2")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
|
||||||
}
|
}
|
||||||
if servingsMultiplier != 1 {
|
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
|
||||||
HStack {
|
HStack() {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
Text("Unable to adjust the highlighted ingredient amount!")
|
Text("Only highlighted ingredient were adjusted!")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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: $servingsMultiplier,
|
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
|
||||||
|
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
|
||||||
recipeId: viewModel.observableRecipeDetail.id
|
recipeId: viewModel.observableRecipeDetail.id
|
||||||
) {
|
) {
|
||||||
groceryList.addItem(
|
groceryList.addItem(
|
||||||
@@ -86,8 +88,11 @@ fileprivate struct IngredientListItem: View {
|
|||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@Binding var ingredient: String
|
@Binding var ingredient: String
|
||||||
@Binding var servings: Double
|
@Binding var servings: Double
|
||||||
|
@State var recipeYield: Double
|
||||||
@State var recipeId: String
|
@State var recipeId: String
|
||||||
let addToGroceryListAction: () -> Void
|
let addToGroceryListAction: () -> Void
|
||||||
|
|
||||||
|
@State var modifiedIngredient: AttributedString = ""
|
||||||
@State var isSelected: Bool = false
|
@State var isSelected: Bool = false
|
||||||
|
|
||||||
// Drag animation
|
// Drag animation
|
||||||
@@ -111,19 +116,25 @@ fileprivate struct IngredientListItem: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(systemName: "circle")
|
Image(systemName: "circle")
|
||||||
}
|
}
|
||||||
if servings == 1 {
|
if servings == Double(recipeYield) {
|
||||||
Text(ingredient)
|
Text(ingredient)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
} else {
|
} else {
|
||||||
let modifiedIngredient = ObservableRecipeDetail.modifyIngredientAmounts(in: ingredient, withFactor: servings)
|
|
||||||
Text(modifiedIngredient)
|
Text(modifiedIngredient)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
.foregroundStyle(modifiedIngredient == ingredient ? .red : .primary)
|
//.foregroundStyle(String(modifiedIngredient.characters) == ingredient ? .red : .primary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.onChange(of: servings) { newServings in
|
||||||
|
if recipeYield == 0 {
|
||||||
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
||||||
|
} else {
|
||||||
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
||||||
|
}
|
||||||
|
}
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isSelected.toggle()
|
isSelected.toggle()
|
||||||
@@ -164,27 +175,40 @@ fileprivate struct IngredientListItem: View {
|
|||||||
|
|
||||||
struct ServingPickerView: View {
|
struct ServingPickerView: View {
|
||||||
@Binding var selectedServingSize: Double
|
@Binding var selectedServingSize: Double
|
||||||
var servingSizes: [Double] {
|
@State var numberFormatter: NumberFormatter
|
||||||
var servingSizes: [Double] = [0.125, 0.25, 0.33, 0.5, 0.66, 0.75, 1]
|
|
||||||
for i in 2...100 {
|
init(selectedServingSize: Binding<Double>) {
|
||||||
servingSizes.append(Double(i))
|
_selectedServingSize = selectedServingSize
|
||||||
}
|
numberFormatter = NumberFormatter()
|
||||||
return servingSizes
|
numberFormatter.usesSignificantDigits = true
|
||||||
|
numberFormatter.maximumFractionDigits = 2
|
||||||
|
numberFormatter.decimalSeparator = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed property to handle the text field input and update the selectedServingSize
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker("Serving Size", selection: Binding(
|
HStack {
|
||||||
get: {
|
Button {
|
||||||
self.selectedServingSize
|
selectedServingSize -= 1
|
||||||
},
|
} label: {
|
||||||
set: { newValue in
|
Image(systemName: "minus.square.fill")
|
||||||
self.selectedServingSize = newValue
|
.bold()
|
||||||
}
|
}
|
||||||
)) {
|
TextField("", value: $selectedServingSize, formatter: numberFormatter)
|
||||||
ForEach(servingSizes, id: \.self) { size in
|
.keyboardType(.numbersAndPunctuation)
|
||||||
Text(ObservableRecipeDetail.formatNumber(size)).tag(size)
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: 40)
|
||||||
|
Button {
|
||||||
|
selectedServingSize += 1
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.square.fill")
|
||||||
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(MenuPickerStyle())
|
.onChange(of: selectedServingSize) { newValue in
|
||||||
|
if newValue <= 0 { selectedServingSize = 1 }
|
||||||
|
else if newValue > 100 { selectedServingSize = 100 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct RecipeMetadataSection: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.popover(isPresented: $presentServingsPopover) {
|
.popover(isPresented: $presentServingsPopover) {
|
||||||
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 0..<99, title: "Servings", titleKey: "Servings")
|
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user