diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 6ed9157..f1eb67e 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; }; + A90C45F42B9F4DB6005D62B6 /* Units.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C45F32B9F4DB6005D62B6 /* Units.swift */; }; A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506122B920D9F00E86029 /* RecipeDurationSection.swift */; }; A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506142B920DF200E86029 /* RecipeGenericViews.swift */; }; A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506182B920EC200E86029 /* RecipeIngredientSection.swift */; }; @@ -125,6 +126,7 @@ A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; + A90C45F32B9F4DB6005D62B6 /* Units.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Units.swift; sourceTree = ""; }; A97506122B920D9F00E86029 /* RecipeDurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDurationSection.swift; sourceTree = ""; }; A97506142B920DF200E86029 /* RecipeGenericViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeGenericViews.swift; sourceTree = ""; }; A97506182B920EC200E86029 /* RecipeIngredientSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeIngredientSection.swift; sourceTree = ""; }; @@ -285,6 +287,7 @@ A70171C52AB4C43A00064C43 /* DataModels.swift */, A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */, + A90C45F32B9F4DB6005D62B6 /* Units.swift */, ); path = Data; sourceTree = ""; @@ -593,6 +596,7 @@ A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A70171C02AB498A900064C43 /* RecipeView.swift in Sources */, + A90C45F42B9F4DB6005D62B6 /* Units.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, @@ -769,7 +773,7 @@ CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_TEAM = EF2ABA36D9; @@ -793,7 +797,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.1; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -813,7 +817,7 @@ CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_TEAM = EF2ABA36D9; @@ -837,7 +841,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.1; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; 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 d4ca057..df42008 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 999d904..268282a 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -83,11 +83,100 @@ class ObservableRecipeDetail: ObservableObject { ) } - func ingredients(for servings: Int) -> [String] { - for ingredient in recipeIngredient { - // TODO: Parse ingredient strings, adjust them for yield + /*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) + } } - return [] + + 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 modifiedIngredient + } + + static func formatNumber(_ value: Double) -> String { + let integerPart = value >= 1 ? Int(value) : 0 + let decimalPart = value - Double(integerPart) + + if integerPart >= 1 && decimalPart < 0.0001 { + return String(format: "%.0f", value) + } + + // Define known fractions and their decimal equivalents + let knownFractions: [(fraction: String, value: Double)] = [ + ("1/8", 0.125), ("1/6", 0.167), ("1/4", 0.25), ("1/3", 0.33), ("1/2", 0.5), ("2/3", 0.66), ("3/4", 0.75) + ] + + // Find the known fraction closest to the given value + let closest = knownFractions.min(by: { abs($0.value - decimalPart) < abs($1.value - decimalPart) })! + + // Check if the value is close enough to a known fraction to be considered a match + let threshold = 0.05 + if abs(closest.value - decimalPart) <= threshold && integerPart == 0 { + return closest.fraction + } else if abs(closest.value - decimalPart) <= threshold && integerPart > 0 { + return "\(String(integerPart)) \(closest.fraction)" + } else { + // If no close match is found, return the original value as a string + return String(format: "%.2f", value) + } + } + + func ingredientUnitsToMetric() { + // TODO: Convert imperial units in recipes to metric units } } diff --git a/Nextcloud Cookbook iOS Client/Data/Units.swift b/Nextcloud Cookbook iOS Client/Data/Units.swift new file mode 100644 index 0000000..de112f5 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/Units.swift @@ -0,0 +1,212 @@ +// +// Units.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 11.03.24. +// + +import Foundation +import SwiftUI + + +// MARK: - Ingredient Units + +enum MeasurementUnit { + // Volume Metric + case milliLiter, centiLiter, deciLiter, liter + + // Volume Imperial + case teaspoon, tablespoon, cup, pint, quart, gallon, gill, fluidOunce // Please just use metric + + // Weight Metric + case milliGram, gram, kilogram + + // Weight Imperial + case ounce, pound + + // Other + case pinch, dash, smidgen + + + var localizedDescription: [LocalizedStringKey] { + switch self { + case .milliLiter: + return ["milliliter", "millilitre", "ml", "cc"] + case .centiLiter: + return ["centiliter", "centilitre", "cl"] + case .deciLiter: + return ["deciliter", "decilitre", "dl"] + case .liter: + return ["liter", "litre", "l"] + case .teaspoon: + return ["teaspoon", "tsp"] + case .tablespoon: + return ["tablespoon", "tbsp"] + case .cup: + return ["cup", "c"] + case .pint: + return ["pint", "pt"] + case .quart: + return ["quart", "qt"] + case .gallon: + return ["gallon", "gal"] + case .gill: + return ["gill", "gi"] + case .fluidOunce: + return ["fluid ounce", "fl oz"] + case .milliGram: + return ["milligram", "mg"] + case .gram: + return ["gram", "g"] + case .kilogram: + return ["kilogram", "kg"] + case .ounce: + return ["ounce", "oz"] + case .pound: + return ["pound", "lb"] + case .pinch: + return ["pinch"] + case .dash: + return ["dash"] + case .smidgen: + return ["smidgen"] + } + } + + static func convert(value: Double, from fromUnit: MeasurementUnit, to toUnit: MeasurementUnit) -> Double? { + let (baseValue, _) = MeasurementUnit.toBaseUnit(value: value, unit: fromUnit) + return MeasurementUnit.fromBaseUnit(value: baseValue, targetUnit: toUnit) + } + + private static func baseUnit(of unit: MeasurementUnit) -> MeasurementUnit { + switch unit { + // Volume Metric (all converted to liters) + case .milliLiter, .centiLiter, .deciLiter, .liter, .teaspoon, .tablespoon, .cup, .pint, .quart, .gallon, .gill, .fluidOunce, .dash: + return .liter + + // Weight (all converted to grams) + case .milliGram, .gram, .kilogram, .ounce, .pound, .pinch, .smidgen: + return .gram + } + } + + private static func toBaseUnit(value: Double, unit: MeasurementUnit) -> (Double, MeasurementUnit) { + guard abs(value) >= Double(1e-10) else { + return (0, unit) + } + switch unit { + case .milliLiter: + return (value/1000, .liter) + case .centiLiter: + return (value/100, .liter) + case .deciLiter: + return (value/10, .liter) + case .liter: + return (value, .liter) + case .teaspoon: + return (value * 0.005, .liter) + case .tablespoon: + return (value * 0.015, .liter) + case .cup: + return (value * 0.25, .liter) + case .pint: + return (value * 0.5, .liter) + case .quart: + return (value * 0.946, .liter) + case .gallon: + return (value * 3.8, .liter) + case .gill: + return (value * 0.17, .liter) + case .fluidOunce: + return (value * 0.03, .liter) + case .milliGram: + return (value * 0.001, .gram) + case .gram: + return (value, .gram) + case .kilogram: + return (value * 1000, .gram) + case .ounce: + return (value * 30, .gram) + case .pound: + return (value * 450, .gram) + case .pinch: + return (value * 0.3, .gram) + case .dash: + return (value * 0.000625, .liter) + case .smidgen: + return (value * 0.15, .gram) + } + } + + static private func fromBaseUnit(value: Double, targetUnit: MeasurementUnit) -> Double { + guard abs(value) >= Double(1e-10) else { + return 0 + } + + switch targetUnit { + case .milliLiter: + return value * 1000 + case .centiLiter: + return value * 100 + case .deciLiter: + return value * 10 + case .liter: + return value + case .teaspoon: + return value / 0.005 + case .tablespoon: + return value / 0.015 + case .cup: + return value / 0.25 + case .pint: + return value / 0.5 + case .quart: + return value / 0.946 + case .gallon: + return value / 3.8 + case .gill: + return value / 0.17 + case .fluidOunce: + return value / 0.03 + case .milliGram: + return value * 1000 + case .gram: + return value + case .kilogram: + return value / 1000 + case .ounce: + return value / 30 + case .pound: + return value / 450 + case .pinch: + return value / 0.3 + case .dash: + return value / 0.000625 + case .smidgen: + return value / 0.15 + } + } +} + + + +enum TemperatureUnit { + case fahrenheit, celsius + + var localizedDescription: [LocalizedStringKey] { + switch self { + case .fahrenheit: + ["fahrenheit", "f"] + case .celsius: + ["celsius", "c"] + } + } + + static func celsiusToFahrenheit(_ celsius: Double) -> Double { + return celsius * 9.0 / 5.0 + 32.0 + } + + static func fahrenheitToCelsius(_ fahrenheit: Double) -> Double { + return (fahrenheit - 32.0) * 5.0 / 9.0 + } +} diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 90f8b34..3ca4f1f 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -89,6 +89,9 @@ } } } + }, + "%.2f" : { + }, "%@" : { "localizations" : { @@ -661,6 +664,9 @@ } } } + }, + "c" : { + }, "Calories" : { "comment" : "Calories", @@ -774,6 +780,18 @@ } } } + }, + "cc" : { + + }, + "celsius" : { + + }, + "centiliter" : { + + }, + "centilitre" : { + }, "Cholesterol content" : { "comment" : "Cholesterol content", @@ -819,6 +837,9 @@ } } } + }, + "cl" : { + }, "Configure what is stored on your device." : { "localizations" : { @@ -1061,6 +1082,18 @@ } } } + }, + "cup" : { + + }, + "dash" : { + + }, + "deciliter" : { + + }, + "decilitre" : { + }, "Delete" : { "localizations" : { @@ -1238,6 +1271,9 @@ } } } + }, + "dl" : { + }, "Done" : { "localizations" : { @@ -1481,6 +1517,12 @@ } } } + }, + "f" : { + + }, + "fahrenheit" : { + }, "Fat content" : { "comment" : "Fat content", @@ -1527,6 +1569,21 @@ } } } + }, + "fl oz" : { + + }, + "fluid ounce" : { + + }, + "g" : { + + }, + "gal" : { + + }, + "gallon" : { + }, "General" : { "localizations" : { @@ -1571,6 +1628,15 @@ } } } + }, + "gi" : { + + }, + "gill" : { + + }, + "gram" : { + }, "Grocery List" : { "localizations" : { @@ -1923,6 +1989,15 @@ } } } + }, + "kg" : { + + }, + "kilogram" : { + + }, + "l" : { + }, "Language" : { "localizations" : { @@ -1989,6 +2064,9 @@ } } } + }, + "lb" : { + }, "List your tools here. 🍴" : { "localizations" : { @@ -2011,6 +2089,12 @@ } } } + }, + "liter" : { + + }, + "litre" : { + }, "Log out" : { "localizations" : { @@ -2121,6 +2205,18 @@ } } } + }, + "mg" : { + + }, + "milligram" : { + + }, + "milliliter" : { + + }, + "millilitre" : { + }, "Minutes" : { "localizations" : { @@ -2211,6 +2307,9 @@ } } } + }, + "ml" : { + }, "More information" : { "localizations" : { @@ -2498,6 +2597,12 @@ } } } + }, + "ounce" : { + + }, + "oz" : { + }, "Parsing error" : { "localizations" : { @@ -2564,6 +2669,12 @@ } } } + }, + "pinch" : { + + }, + "pint" : { + }, "Please check the entered URL." : { "localizations" : { @@ -2630,6 +2741,9 @@ } } } + }, + "pound" : { + }, "Preparation" : { "localizations" : { @@ -2698,6 +2812,15 @@ } } } + }, + "pt" : { + + }, + "qt" : { + + }, + "quart" : { + }, "Recipe" : { "localizations" : { @@ -3008,6 +3131,9 @@ } } } + }, + "Serving Size" : { + }, "Servings" : { "localizations" : { @@ -3186,6 +3312,9 @@ } } } + }, + "smidgen" : { + }, "Sodium content" : { "comment" : "Sodium content", @@ -3386,6 +3515,15 @@ } } } + }, + "tablespoon" : { + + }, + "tbsp" : { + + }, + "teaspoon" : { + }, "Thank you for downloading" : { "localizations" : { @@ -3721,6 +3859,12 @@ } } } + }, + "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 8364395..d790bb2 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -13,6 +13,7 @@ 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) { @@ -25,6 +26,7 @@ struct RecipeIngredientSection: View { SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings")) } Spacer() + ServingPickerView(selectedServingSize: $servingsMultiplier) Button { withAnimation { if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { @@ -45,9 +47,19 @@ struct RecipeIngredientSection: View { } }.disabled(viewModel.editMode) } - + if servingsMultiplier != 1 { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("Unable to adjust the highlighted ingredient amount!") + } + } ForEach(0.. Void @State var isSelected: Bool = false @@ -98,10 +111,17 @@ fileprivate struct IngredientListItem: View { } else { Image(systemName: "circle") } - - Text("\(ingredient)") - .multilineTextAlignment(.leading) - .lineLimit(5) + if servings == 1 { + 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) + } Spacer() } .foregroundStyle(isSelected ? Color.secondary : Color.primary) @@ -139,3 +159,32 @@ 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 + } + + var body: some View { + Picker("Serving Size", selection: Binding( + get: { + self.selectedServingSize + }, + set: { newValue in + self.selectedServingSize = newValue + } + )) { + ForEach(servingSizes, id: \.self) { size in + Text(ObservableRecipeDetail.formatNumber(size)).tag(size) + } + } + .pickerStyle(MenuPickerStyle()) + } +}