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 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,69 +91,73 @@ 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))
var attributedString = AttributedString(ingredient)
// Reverse the matches to replace from the end to avoid affecting indices of unprocessed matches for (newSubstring, matchRange) in matches {
let reversedMatches = matches.reversed() print(newSubstring, matchRange)
guard let range = Range(matchRange, in: attributedString) else { continue }
var modifiedIngredient = ingredient var attributedSubString = AttributedString(newSubstring)
attributedSubString.foregroundColor = .blue
for match in reversedMatches { attributedString.replaceSubrange(range, with: attributedSubString)
guard let range = Range(match.range, in: modifiedIngredient) else { continue } print("\n", attributedString)
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)
}
} }
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 static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range<String.Index>]? = nil) -> [(String, Range<String.Index>)] {
let regexPattern = "\\b(\\d+(\\.\\d+)?)\\b|\\b(\\d+/\\d+)\\b" var foundMatches: [(String, Range<String.Index>)] = []
let regex = try! NSRegularExpression(pattern: regexPattern, options: []) do {
let matches = regex.matches(in: ingredient, options: [], range: NSRange(ingredient.startIndex..., in: ingredient)) let regex = try NSRegularExpression(pattern: expr.pattern)
let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str))
var modifiedIngredient = ingredient for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
// Reverse the matches to replace from the end to avoid affecting indices of unprocessed matches if let excludedRanges = excludedRanges,
let reversedMatches = matches.reversed() excludedRanges.contains(where: { $0.overlaps(matchRange) }) {
// If there's an overlap, skip this match.
for match in reversedMatches { continue
let fullMatchRange = match.range(at: 0) }
// Check for a fractional match let matchedString = String(str[matchRange])
if match.range(at: 3).location != NSNotFound, let fractionRange = Range(match.range(at: 3), in: modifiedIngredient) {
let fractionString = String(modifiedIngredient[fractionRange]) // Process each match based on its type
let fractionParts = fractionString.split(separator: "/").compactMap { Double($0) } var adjustedValue: Double = 0
if fractionParts.count == 2, let numerator = fractionParts.first, let denominator = fractionParts.last, denominator != 0 { switch expr {
let fractionValue = numerator / denominator case .number:
let modifiedNumber = fractionValue * factor guard let number = Double(matchedString) else { continue }
let formattedNumber = formatNumber(modifiedNumber) adjustedValue = number
modifiedIngredient.replaceSubrange(fractionRange, with: formattedNumber) case .fraction:
} let fracComponents = matchedString.split(separator: "/")
} guard fracComponents.count == 2 else { continue }
// Check for an integer or decimal match guard let nominator = Double(fracComponents[0]) else { continue }
else if let numberRange = Range(fullMatchRange, in: modifiedIngredient) { guard let denominator = Double(fracComponents[1]), denominator > 0 else { continue }
let numberString = String(modifiedIngredient[numberRange]) adjustedValue = nominator/denominator
if let number = Double(numberString) { case .mixedFraction:
let modifiedNumber = number * factor guard match.numberOfRanges == 4 else { continue }
let formattedNumber = formatNumber(modifiedNumber) guard let intRange = Range(match.range(at: 1), in: str) else { continue }
modifiedIngredient.replaceSubrange(numberRange, with: formattedNumber) 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
return modifiedIngredient } catch {
print("Regex error: \(error.localizedDescription)")
} }
return []
}
static func formatNumber(_ value: Double) -> String { static func formatNumber(_ value: Double) -> String {
let integerPart = value >= 1 ? Int(value) : 0 let integerPart = value >= 1 ? Int(value) : 0
@@ -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" : { "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" : {

View File

@@ -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 {
servingSizes.append(Double(i))
}
return servingSizes
}
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 { 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 }
}
} }
} }

View File

@@ -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")
} }
} }
} }