diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 6ed9157..d7948a5 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -56,7 +56,7 @@ A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; }; A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; }; - A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = A9A43AE02B963150003D95CA /* SwipeActions */; }; + A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; }; A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; }; A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; }; A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */; }; @@ -137,6 +137,7 @@ A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = ""; }; A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = ""; }; A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; + A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = ""; }; A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = ""; }; A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = ""; }; @@ -153,7 +154,6 @@ buildActionMask = 2147483647; files = ( A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, - A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -372,6 +372,7 @@ isa = PBXGroup; children = ( A76B8A702AE002AE00096CEC /* Alerts.swift */, + A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */, A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */, A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */, ); @@ -436,7 +437,6 @@ packageProductDependencies = ( A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */, - A9A43AE02B963150003D95CA /* SwipeActions */, ); productName = "Nextcloud Cookbook iOS Client"; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; @@ -516,7 +516,6 @@ packageReferences = ( A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, - A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */, ); productRefGroup = A701717F2AA8E71900064C43 /* Products */; projectDirPath = ""; @@ -567,6 +566,7 @@ A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */, + A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, @@ -769,7 +769,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 +793,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 +813,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 +837,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; @@ -993,14 +993,6 @@ minimumVersion = 2.6.1; }; }; - A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/aheze/SwipeActions"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/techprimate/TPPDF.git"; @@ -1017,11 +1009,6 @@ package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - A9A43AE02B963150003D95CA /* SwipeActions */ = { - isa = XCSwiftPackageProductDependency; - package = A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */; - productName = SwipeActions; - }; A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { isa = XCSwiftPackageProductDependency; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 98ab635..d488c1a 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,15 +9,6 @@ "version" : "2.6.1" } }, - { - "identity" : "swipeactions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/aheze/SwipeActions", - "state" : { - "revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab", - "version" : "1.1.0" - } - }, { "identity" : "tppdf", "kind" : "remoteSourceControl", 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..21581c2 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/Assets.xcassets/textHighlight.colorset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/textHighlight.colorset/Contents.json new file mode 100644 index 0000000..8385deb --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Assets.xcassets/textHighlight.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC9", + "green" : "0x82", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC9", + "green" : "0x82", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC9", + "green" : "0x82", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index 999d904..bf3ce30 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,11 @@ 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 +41,14 @@ class ObservableRecipeDetail: ObservableObject { totalTime = DurationComponents() description = "" url = "" - recipeYield = 0 + recipeYield = 1 recipeCategory = "" tool = [] recipeIngredient = [] recipeInstructions = [] nutrition = [:] + + ingredientMultiplier = 1 } init(_ recipeDetail: RecipeDetail) { @@ -53,12 +61,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 == 0 ? 1 : recipeDetail.recipeYield) } func toRecipeDetail() -> RecipeDetail { @@ -83,13 +93,159 @@ class ObservableRecipeDetail: ObservableObject { ) } - func ingredients(for servings: Int) -> [String] { - for ingredient in recipeIngredient { - // TODO: Parse ingredient strings, adjust them for yield + static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString { + if factor == 0 { + return AttributedString(ingredient) + } + // Match mixed fractions first + var matches = ObservableRecipeDetail.matchPatternAndMultiply( + .mixedFraction, + in: ingredient, + multFactor: factor + ) + // Then match fractions, exclude mixed fraction ranges + matches.append(contentsOf: + ObservableRecipeDetail.matchPatternAndMultiply( + .fraction, + in: ingredient, + multFactor: factor, + excludedRanges: matches.map({ tuple in tuple.1 }) + ) + ) + // Match numbers at last, exclude all prior matches + matches.append(contentsOf: + ObservableRecipeDetail.matchPatternAndMultiply( + .number, + in: ingredient, + multFactor: factor, + excludedRanges: matches.map({ tuple in tuple.1 }) + ) + ) + // Sort matches by match range lower bound, descending. + matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound}) + + var attributedString = AttributedString(ingredient) + for (newSubstring, matchRange) in matches { + guard let range = Range(matchRange, in: attributedString) else { continue } + var attributedSubString = AttributedString(newSubstring) + //attributedSubString.foregroundColor = .ncTextHighlight + attributedSubString.font = .system(.body, weight: .bold) + attributedString.replaceSubrange(range, with: attributedSubString) + } + + 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)) + + 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 = numberFormatter.number(from: matchedString) else { continue } + adjustedValue = number.doubleValue + 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 { + if value <= 0.0001 { + return "0" + } + 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 numberFormatter.string(from: NSNumber(value: value)) ?? "0"//String(format: "%.2f", value) + } + } + + func ingredientUnitsToMetric() { + // TODO: Convert imperial units in recipes to metric units + } } - +enum RegexPattern: String, CaseIterable, Identifiable { + case mixedFraction, fraction, number + + var id: String { self.rawValue } + + 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+)?)"# + } + } + + var localizedDescription: LocalizedStringKey { + switch self { + case .mixedFraction: + "Mixed fraction" + case .fraction: + "Fraction" + case .number: + "Number" + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index de942c4..cddb099 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -115,6 +115,12 @@ class UserSettings: ObservableObject { } } + @Published var decimalNumberSeparator: String { + didSet { + UserDefaults.standard.set(decimalNumberSeparator, forKey: "decimalNumberSeparator") + } + } + init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" @@ -133,6 +139,7 @@ class UserSettings: ObservableObject { self.expandKeywordSection = UserDefaults.standard.object(forKey: "expandKeywordSection") as? Bool ?? false self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true + self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "." if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift index 439e045..d2a2699 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift @@ -27,4 +27,7 @@ extension Color { public static var ncGradientLight: Color { return Color("ncgradientlightblue") } + public static var ncTextHighlight: Color { + return Color("textHighlight") + } } diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 90f8b34..9df0669 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -90,6 +90,28 @@ } } }, + "%.2f" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.2f" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, "%@" : { "localizations" : { "de" : { @@ -820,6 +842,28 @@ } } }, + "Comma (e.g. 1,42)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Komma (z.B. 1,42)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coma (por ejemplo, 1,42)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virgule (par exemple, 1,42)" + } + } + } + }, "Configure what is stored on your device." : { "localizations" : { "de" : { @@ -1062,6 +1106,28 @@ } } }, + "Decimal number format" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezimalzahlenformat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formato de número decimal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format de nombre décimal" + } + } + } + }, "Delete" : { "localizations" : { "de" : { @@ -1528,6 +1594,28 @@ } } }, + "Fraction" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fracción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fraction" + } + } + } + }, "General" : { "localizations" : { "de" : { @@ -1793,6 +1881,7 @@ } }, "Ingredients for %lld servings" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1815,6 +1904,7 @@ } }, "Ingredients per serving" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2122,6 +2212,28 @@ } } }, + "Marked ingredients could not be adjusted!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markierte Zutaten können nicht angepasst werden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Los ingredientes marcados no pudieron ser ajustados!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les ingrédients marqués n'ont pas pu être ajustés!" + } + } + } + }, "Minutes" : { "localizations" : { "de" : { @@ -2212,6 +2324,28 @@ } } }, + "Mixed fraction" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemischter Bruch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fracción mixta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fraction mixte" + } + } + } + }, "More information" : { "localizations" : { "de" : { @@ -2389,6 +2523,28 @@ } } }, + "Number" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezimalzahl" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + } + } + }, "Nutrition" : { "localizations" : { "de" : { @@ -2631,6 +2787,28 @@ } } }, + "Point (e.g. 1.42)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punkt (z.B. 1.42)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punto (por ejemplo, 1.42)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Point (par exemple, 1.42)" + } + } + } + }, "Preparation" : { "localizations" : { "de" : { @@ -2787,6 +2965,28 @@ } } }, + "Refresh" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refrescar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualiser" + } + } + } + }, "Refresh all" : { "localizations" : { "de" : { @@ -3215,7 +3415,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Hier fehlen Zutaten! 🥬" + "value" : "Hier ist Platz für Zutaten! 🥬" } }, "es" : { @@ -3476,6 +3676,28 @@ } } }, + "There are no recipes in this cookbook!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hier gibt es momentan noch keine Rezepte zu sehen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay recetas en esta categoría." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il n'y a pas de recettes dans cette catégorie." + } + } + } + }, "There was no name in the request given for the recipe. Cannot save the recipe." : { "extractionState" : "stale", "localizations" : { @@ -3543,6 +3765,28 @@ } } }, + "This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Einstellung wird erst nach einem Neustart der App wirksam. Die Einstellung betrifft die Mengenberechnung der Rezeptzutaten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta configuración surtirá efecto después de reiniciar la aplicación. Afecta el ajuste de las cantidades de ingredientes." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce paramètre prendra effet après le redémarrage de l'application. Il affecte l'ajustement des quantités d'ingrédients." + } + } + } + }, "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : { "localizations" : { "de" : { diff --git a/Nextcloud Cookbook iOS Client/Util/NumberFormatter.swift b/Nextcloud Cookbook iOS Client/Util/NumberFormatter.swift new file mode 100644 index 0000000..213a003 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Util/NumberFormatter.swift @@ -0,0 +1,22 @@ +// +// Locale.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 20.03.24. +// + +import Foundation + + +// Ingredient number formatting +func getNumberFormatter() -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.decimalSeparator = UserSettings.shared.decimalNumberSeparator + return formatter +} + +let numberFormatter = getNumberFormatter() + + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 147914d..304d0a2 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -19,21 +19,40 @@ struct RecipeListView: View { @State var selectedRecipe: Recipe? = nil var body: some View { - List(recipesFiltered(), id: \.recipe_id) { recipe in - RecipeCardView(recipe: recipe) - .shadow(radius: 2) - .background( - NavigationLink(value: recipe) { - EmptyView() + Group { + let recipes = recipesFiltered() + if !recipes.isEmpty { + List(recipesFiltered(), id: \.recipe_id) { recipe in + RecipeCardView(recipe: recipe) + .shadow(radius: 2) + .background( + NavigationLink(value: recipe) { + EmptyView() + } + .buttonStyle(.plain) + .opacity(0) + ) + .frame(height: 85) + .listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15)) + .listRowSeparatorTint(.clear) + } + .listStyle(.plain) + } else { + VStack { + Text("There are no recipes in this cookbook!") + Button { + Task { + await appState.getCategories() + await appState.getCategory(named: categoryName, fetchMode: .preferServer) + } + } label: { + Text("Refresh") + .bold() } - .buttonStyle(.plain) - .opacity(0) - ) - .frame(height: 85) - .listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15)) - .listRowSeparatorTint(.clear) + .buttonStyle(.bordered) + }.padding() + } } - .listStyle(.plain) .searchable(text: $searchText, prompt: "Search recipes/keywords") .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index 8364395..25326d0 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -17,14 +17,6 @@ struct RecipeIngredientSection: View { 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() Button { withAnimation { if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { @@ -44,10 +36,25 @@ 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) } ForEach(0.. Void + + @State var modifiedIngredient: AttributedString = "" @State var isSelected: Bool = false + var unmodified: Bool { + servings == Double(recipeYield) || servings == 0 + } // Drag animation @State private var dragOffset: CGFloat = 0 @@ -98,12 +124,29 @@ fileprivate struct IngredientListItem: View { } else { Image(systemName: "circle") } - - Text("\(ingredient)") - .multilineTextAlignment(.leading) - .lineLimit(5) + if !unmodified && String(modifiedIngredient.characters) == ingredient { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + if unmodified { + Text(ingredient) + .multilineTextAlignment(.leading) + .lineLimit(5) + } else { + Text(modifiedIngredient) + .multilineTextAlignment(.leading) + .lineLimit(5) + //.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() @@ -139,3 +182,36 @@ fileprivate struct IngredientListItem: View { ) } } + + + +struct ServingPickerView: View { + @Binding var selectedServingSize: Double + + // Computed property to handle the text field input and update the selectedServingSize + var body: some View { + HStack { + Button { + selectedServingSize -= 1 + } label: { + Image(systemName: "minus.square.fill") + .bold() + } + TextField("", value: $selectedServingSize, formatter: numberFormatter) + .keyboardType(.numbersAndPunctuation) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(width: 40) + Button { + selectedServingSize += 1 + } label: { + Image(systemName: "plus.square.fill") + .bold() + } + } + .onChange(of: selectedServingSize) { newValue in + if newValue < 0 { selectedServingSize = 0 } + 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") } } } diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index cc49369..71a4167 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -76,6 +76,20 @@ struct SettingsView: View { } } + Section { + HStack { + Text("Decimal number format") + Spacer() + Picker("", selection: $userSettings.decimalNumberSeparator) { + Text("Point (e.g. 1.42)").tag(".") + Text("Comma (e.g. 1,42)").tag(",") + } + .pickerStyle(.menu) + } + } footer: { + Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.") + } + Section { Toggle(isOn: $userSettings.storeRecipes) { Text("Offline recipes") @@ -170,12 +184,6 @@ struct SettingsView: View { } message: { Text(viewModel.alertType.getMessage()) } - .onDisappear { - Task { - userSettings.lastUpdate = .distantPast - await appState.updateAllRecipeDetails() - } - } .task { await viewModel.getUserData() } @@ -239,3 +247,4 @@ extension SettingsView { + diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index d9c6199..c060116 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -19,28 +19,34 @@ struct RecipeTabView: View { List(selection: $viewModel.selectedCategory) { // Categories ForEach(appState.categories) { category in - if category.recipe_count != 0 { - NavigationLink(value: category) { - HStack(alignment: .center) { - if viewModel.selectedCategory != nil && category.name == viewModel.selectedCategory!.name { - Image(systemName: "book") - } else { - Image(systemName: "book.closed.fill") - } - Text(category.name == "*" ? String(localized: "Other") : category.name) + NavigationLink(value: category) { + HStack(alignment: .center) { + if viewModel.selectedCategory != nil && + category.name == viewModel.selectedCategory!.name { + Image(systemName: "book") + } else { + Image(systemName: "book.closed.fill") + } + + if category.name == "*" { + Text("Other") .font(.system(size: 20, weight: .medium, design: .default)) - Spacer() - Text("\(category.recipe_count)") - .font(.system(size: 15, weight: .bold, design: .default)) - .foregroundStyle(Color.background) - .frame(width: 25, height: 25, alignment: .center) - .minimumScaleFactor(0.5) - .background { - Circle() - .foregroundStyle(Color.secondary) - } - }.padding(7) - } + } else { + Text(category.name) + .font(.system(size: 20, weight: .medium, design: .default)) + } + + Spacer() + Text("\(category.recipe_count)") + .font(.system(size: 15, weight: .bold, design: .default)) + .foregroundStyle(Color.background) + .frame(width: 25, height: 25, alignment: .center) + .minimumScaleFactor(0.5) + .background { + Circle() + .foregroundStyle(Color.secondary) + } + }.padding(7) } } } @@ -100,7 +106,7 @@ struct RecipeTabView: View { fileprivate struct RecipeTabViewToolBar: ToolbarContent { - @EnvironmentObject var mainViewModel: AppState + @EnvironmentObject var appState: AppState @EnvironmentObject var viewModel: RecipeTabView.ViewModel var body: some ToolbarContent { @@ -111,11 +117,11 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { Task { viewModel.presentLoadingIndicator = true UserSettings.shared.lastUpdate = Date.distantPast - await mainViewModel.getCategories() - for category in mainViewModel.categories { - await mainViewModel.getCategory(named: category.name, fetchMode: .preferServer) + await appState.getCategories() + for category in appState.categories { + await appState.getCategory(named: category.name, fetchMode: .preferServer) } - await mainViewModel.updateAllRecipeDetails() + await appState.updateAllRecipeDetails() viewModel.presentLoadingIndicator = false } } label: {