Adjustable recipe ingredient amount

This commit is contained in:
VincentMeilinger
2024-03-18 09:09:49 +01:00
parent eae72eb0ce
commit 054e222d8e
5 changed files with 148 additions and 100 deletions

View File

@@ -9,6 +9,7 @@ import Foundation
import SwiftUI
class ObservableRecipeDetail: ObservableObject {
// Cookbook recipe detail fields
var id: String
@Published var name: String
@Published var keywords: [String]
@@ -25,6 +26,9 @@ class ObservableRecipeDetail: ObservableObject {
@Published var recipeInstructions: [String]
@Published var nutrition: [String:String]
// Additional functionality
@Published var ingredientMultiplier: Double
init() {
id = ""
name = String(localized: "New Recipe")
@@ -35,12 +39,14 @@ class ObservableRecipeDetail: ObservableObject {
totalTime = DurationComponents()
description = ""
url = ""
recipeYield = 0
recipeYield = 1
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
ingredientMultiplier = 1
}
init(_ recipeDetail: RecipeDetail) {
@@ -53,12 +59,14 @@ class ObservableRecipeDetail: ObservableObject {
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
description = recipeDetail.description
url = recipeDetail.url
recipeYield = recipeDetail.recipeYield
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
recipeCategory = recipeDetail.recipeCategory
tool = recipeDetail.tool
recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = recipeDetail.recipeInstructions
nutrition = recipeDetail.nutrition
ingredientMultiplier = Double(recipeDetail.recipeYield)
}
func toRecipeDetail() -> RecipeDetail {
@@ -83,68 +91,72 @@ class ObservableRecipeDetail: ObservableObject {
)
}
/*static func modifyIngredientAmounts(in ingredient: String, withFactor factor: Double) -> String {
// Regular expression to match numbers, including integers and decimals
// Patterns:
// "\\b\\d+(\\.\\d+)?\\b" works only if there is a space following
let regex = try! NSRegularExpression(pattern: "\\b\\d+(\\.\\d+)?\\b", options: [])
let matches = regex.matches(in: ingredient, options: [], range: NSRange(ingredient.startIndex..., in: ingredient))
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
var matches = ObservableRecipeDetail.matchPatternAndMultiply(.mixedFraction, in: ingredient, multFactor: factor)
matches.append(contentsOf: ObservableRecipeDetail.matchPatternAndMultiply(.fraction, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 })))
matches.append(contentsOf: ObservableRecipeDetail.matchPatternAndMultiply(.number, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 })))
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
// Reverse the matches to replace from the end to avoid affecting indices of unprocessed matches
let reversedMatches = matches.reversed()
var modifiedIngredient = ingredient
for match in reversedMatches {
guard let range = Range(match.range, in: modifiedIngredient) else { continue }
let originalNumberString = String(modifiedIngredient[range])
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)
}
var attributedString = AttributedString(ingredient)
for (newSubstring, matchRange) in matches {
print(newSubstring, matchRange)
guard let range = Range(matchRange, in: attributedString) else { continue }
var attributedSubString = AttributedString(newSubstring)
attributedSubString.foregroundColor = .blue
attributedString.replaceSubrange(range, with: attributedSubString)
print("\n", attributedString)
}
return modifiedIngredient
}*/
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 attributedString
}
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 {
@@ -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+)?)"#
}
}
}

View File

@@ -1859,6 +1859,7 @@
}
},
"Ingredients for %lld servings" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1881,6 +1882,7 @@
}
},
"Ingredients per serving" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2575,6 +2577,9 @@
}
}
}
},
"Only highlighted ingredient were adjusted!" : {
},
"Other" : {
"localizations" : {
@@ -3131,9 +3136,6 @@
}
}
}
},
"Serving Size" : {
},
"Servings" : {
"localizations" : {
@@ -3862,9 +3864,6 @@
},
"tsp" : {
},
"Unable to adjust the highlighted ingredient amount!" : {
},
"Unable to complete action." : {
"localizations" : {

View File

@@ -13,20 +13,10 @@ import SwiftUI
struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@ObservedObject var viewModel: RecipeView.ViewModel
@State var servingsMultiplier: Double = 1
var body: some View {
VStack(alignment: .leading) {
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 {
withAnimation {
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
@@ -46,18 +36,30 @@ struct RecipeIngredientSection: View {
Image(systemName: "heart.text.square")
}
}.disabled(viewModel.editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
Spacer()
Image(systemName: "person.2")
.foregroundStyle(.secondary)
.bold()
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
}
if servingsMultiplier != 1 {
HStack {
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
HStack() {
Image(systemName: "exclamationmark.triangle.fill")
.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
IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $servingsMultiplier,
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id
) {
groceryList.addItem(
@@ -86,8 +88,11 @@ fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@Binding var ingredient: String
@Binding var servings: Double
@State var recipeYield: Double
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false
// Drag animation
@@ -111,19 +116,25 @@ fileprivate struct IngredientListItem: View {
} else {
Image(systemName: "circle")
}
if servings == 1 {
if servings == Double(recipeYield) {
Text(ingredient)
.multilineTextAlignment(.leading)
.lineLimit(5)
} else {
let modifiedIngredient = ObservableRecipeDetail.modifyIngredientAmounts(in: ingredient, withFactor: servings)
Text(modifiedIngredient)
.multilineTextAlignment(.leading)
.lineLimit(5)
.foregroundStyle(modifiedIngredient == ingredient ? .red : .primary)
//.foregroundStyle(String(modifiedIngredient.characters) == ingredient ? .red : .primary)
}
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)
.onTapGesture {
isSelected.toggle()
@@ -164,27 +175,40 @@ fileprivate struct IngredientListItem: View {
struct ServingPickerView: View {
@Binding var selectedServingSize: Double
var servingSizes: [Double] {
var servingSizes: [Double] = [0.125, 0.25, 0.33, 0.5, 0.66, 0.75, 1]
for i in 2...100 {
servingSizes.append(Double(i))
}
return servingSizes
@State var numberFormatter: NumberFormatter
init(selectedServingSize: Binding<Double>) {
_selectedServingSize = selectedServingSize
numberFormatter = NumberFormatter()
numberFormatter.usesSignificantDigits = true
numberFormatter.maximumFractionDigits = 2
numberFormatter.decimalSeparator = "."
}
// Computed property to handle the text field input and update the selectedServingSize
var body: some View {
Picker("Serving Size", selection: Binding(
get: {
self.selectedServingSize
},
set: { newValue in
self.selectedServingSize = newValue
HStack {
Button {
selectedServingSize -= 1
} label: {
Image(systemName: "minus.square.fill")
.bold()
}
)) {
ForEach(servingSizes, id: \.self) { size in
Text(ObservableRecipeDetail.formatNumber(size)).tag(size)
TextField("", value: $selectedServingSize, formatter: numberFormatter)
.keyboardType(.numbersAndPunctuation)
.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 }
}
}
}

View File

@@ -74,7 +74,7 @@ struct RecipeMetadataSection: View {
.lineLimit(1)
}
.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")
}
}
}