diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index df42008..7e65337 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index 268282a..dd2b0eb 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -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,69 +91,73 @@ 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)) - - // 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) - } + 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}) + + 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)) + return attributedString + } + + static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range]? = nil) -> [(String, Range)] { + var foundMatches: [(String, Range)] = [] + do { + let regex = try NSRegularExpression(pattern: expr.pattern) + let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str)) - 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) + 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 + } - // 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) - } + 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 modifiedIngredient + return foundMatches + } catch { + print("Regex error: \(error.localizedDescription)") } + return [] + } static func formatNumber(_ value: Double) -> String { 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+)?)"# + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 3ca4f1f..f2c0a01 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -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" : { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index d790bb2..f556976 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -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.. 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) { + _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 } + } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift index 69139e3..9f9f89c 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -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") } } }