Merge pull request #21 from VincentMeilinger/1.10

1.10
This commit is contained in:
VincentM
2024-03-26 18:18:35 +01:00
committed by GitHub
14 changed files with 673 additions and 97 deletions

View File

@@ -56,7 +56,7 @@
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; }; A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.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 */; }; A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; }; A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.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 = "<group>"; }; A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; }; A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; };
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; }; A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; };
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; }; A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; }; A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = "<group>"; }; A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = "<group>"; };
@@ -153,7 +154,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */,
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -372,6 +372,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A76B8A702AE002AE00096CEC /* Alerts.swift */, A76B8A702AE002AE00096CEC /* Alerts.swift */,
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */,
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */, A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */,
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */, A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
); );
@@ -436,7 +437,6 @@
packageProductDependencies = ( packageProductDependencies = (
A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
A9CA6CF52B4C63F200F78AB5 /* TPPDF */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
A9A43AE02B963150003D95CA /* SwipeActions */,
); );
productName = "Nextcloud Cookbook iOS Client"; productName = "Nextcloud Cookbook iOS Client";
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
@@ -516,7 +516,6 @@
packageReferences = ( packageReferences = (
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */,
); );
productRefGroup = A701717F2AA8E71900064C43 /* Products */; productRefGroup = A701717F2AA8E71900064C43 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -567,6 +566,7 @@
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */, A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */,
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.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_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
@@ -793,7 +793,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.9.1; MARKETING_VERSION = 1.10;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -813,7 +813,7 @@
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
@@ -837,7 +837,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.9.1; MARKETING_VERSION = 1.10;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -993,14 +993,6 @@
minimumVersion = 2.6.1; 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" */ = { A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/techprimate/TPPDF.git"; repositoryURL = "https://github.com/techprimate/TPPDF.git";
@@ -1017,11 +1009,6 @@
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup; productName = SwiftSoup;
}; };
A9A43AE02B963150003D95CA /* SwipeActions */ = {
isa = XCSwiftPackageProductDependency;
package = A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */;
productName = SwipeActions;
};
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;

View File

@@ -9,15 +9,6 @@
"version" : "2.6.1" "version" : "2.6.1"
} }
}, },
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/aheze/SwipeActions",
"state" : {
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
"version" : "1.1.0"
}
},
{ {
"identity" : "tppdf", "identity" : "tppdf",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

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

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,11 @@ 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 +41,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 +61,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 == 0 ? 1 : recipeDetail.recipeYield)
} }
func toRecipeDetail() -> RecipeDetail { func toRecipeDetail() -> RecipeDetail {
@@ -83,13 +93,159 @@ class ObservableRecipeDetail: ObservableObject {
) )
} }
func ingredients(for servings: Int) -> [String] { static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
for ingredient in recipeIngredient { if factor == 0 {
// TODO: Parse ingredient strings, adjust them for yield 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<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 = 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 [] 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"
}
}
}

View File

@@ -115,6 +115,12 @@ class UserSettings: ObservableObject {
} }
} }
@Published var decimalNumberSeparator: String {
didSet {
UserDefaults.standard.set(decimalNumberSeparator, forKey: "decimalNumberSeparator")
}
}
init() { init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") 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.expandKeywordSection = UserDefaults.standard.object(forKey: "expandKeywordSection") as? Bool ?? false
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
if authString == "" { if authString == "" {
if token != "" && username != "" { if token != "" && username != "" {

View File

@@ -27,4 +27,7 @@ extension Color {
public static var ncGradientLight: Color { public static var ncGradientLight: Color {
return Color("ncgradientlightblue") return Color("ncgradientlightblue")
} }
public static var ncTextHighlight: Color {
return Color("textHighlight")
}
} }

View File

@@ -90,6 +90,28 @@
} }
} }
}, },
"%.2f" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%.2f"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "%.2f"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : ""
}
}
}
},
"%@" : { "%@" : {
"localizations" : { "localizations" : {
"de" : { "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." : { "Configure what is stored on your device." : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Delete" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "General" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1793,6 +1881,7 @@
} }
}, },
"Ingredients for %lld servings" : { "Ingredients for %lld servings" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1815,6 +1904,7 @@
} }
}, },
"Ingredients per serving" : { "Ingredients per serving" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "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" : { "Minutes" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "More information" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Nutrition" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Preparation" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Refresh all" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3215,7 +3415,7 @@
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Hier fehlen Zutaten! 🥬" "value" : "Hier ist Platz für Zutaten! 🥬"
} }
}, },
"es" : { "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." : { "There was no name in the request given for the recipe. Cannot save the recipe." : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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." : { "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" : { "localizations" : {
"de" : { "de" : {

View File

@@ -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()

View File

@@ -19,6 +19,9 @@ struct RecipeListView: View {
@State var selectedRecipe: Recipe? = nil @State var selectedRecipe: Recipe? = nil
var body: some View { var body: some View {
Group {
let recipes = recipesFiltered()
if !recipes.isEmpty {
List(recipesFiltered(), id: \.recipe_id) { recipe in List(recipesFiltered(), id: \.recipe_id) { recipe in
RecipeCardView(recipe: recipe) RecipeCardView(recipe: recipe)
.shadow(radius: 2) .shadow(radius: 2)
@@ -34,6 +37,22 @@ struct RecipeListView: View {
.listRowSeparatorTint(.clear) .listRowSeparatorTint(.clear)
} }
.listStyle(.plain) .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(.bordered)
}.padding()
}
}
.searchable(text: $searchText, prompt: "Search recipes/keywords") .searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)

View File

@@ -17,14 +17,6 @@ struct RecipeIngredientSection: View {
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()
Button { Button {
withAnimation { withAnimation {
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
@@ -44,10 +36,25 @@ 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)
} }
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) { IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id
) {
groceryList.addItem( groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix], viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id, toRecipe: viewModel.observableRecipeDetail.id,
@@ -56,6 +63,16 @@ struct RecipeIngredientSection: View {
} }
.padding(4) .padding(4)
} }
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
HStack() {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary)
Text("Marked ingredients could not be adjusted!")
.foregroundStyle(.secondary)
}.padding(.top)
}
if viewModel.editMode { if viewModel.editMode {
Button { Button {
viewModel.presentIngredientEditView.toggle() viewModel.presentIngredientEditView.toggle()
@@ -64,7 +81,9 @@ struct RecipeIngredientSection: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
}.padding() }
.padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
} }
} }
@@ -73,9 +92,16 @@ struct RecipeIngredientSection: View {
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryList
@Binding var ingredient: String @Binding var ingredient: String
@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
var unmodified: Bool {
servings == Double(recipeYield) || servings == 0
}
// Drag animation // Drag animation
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
@@ -98,12 +124,29 @@ fileprivate struct IngredientListItem: View {
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
} }
if !unmodified && String(modifiedIngredient.characters) == ingredient {
Text("\(ingredient)") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
if unmodified {
Text(ingredient)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(5) .lineLimit(5)
} else {
Text(modifiedIngredient)
.multilineTextAlignment(.leading)
.lineLimit(5)
//.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()
@@ -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 }
}
}
}

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

View File

@@ -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 { Section {
Toggle(isOn: $userSettings.storeRecipes) { Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes") Text("Offline recipes")
@@ -170,12 +184,6 @@ struct SettingsView: View {
} message: { } message: {
Text(viewModel.alertType.getMessage()) Text(viewModel.alertType.getMessage())
} }
.onDisappear {
Task {
userSettings.lastUpdate = .distantPast
await appState.updateAllRecipeDetails()
}
}
.task { .task {
await viewModel.getUserData() await viewModel.getUserData()
} }
@@ -239,3 +247,4 @@ extension SettingsView {

View File

@@ -19,16 +19,23 @@ struct RecipeTabView: View {
List(selection: $viewModel.selectedCategory) { List(selection: $viewModel.selectedCategory) {
// Categories // Categories
ForEach(appState.categories) { category in ForEach(appState.categories) { category in
if category.recipe_count != 0 {
NavigationLink(value: category) { NavigationLink(value: category) {
HStack(alignment: .center) { HStack(alignment: .center) {
if viewModel.selectedCategory != nil && category.name == viewModel.selectedCategory!.name { if viewModel.selectedCategory != nil &&
category.name == viewModel.selectedCategory!.name {
Image(systemName: "book") Image(systemName: "book")
} else { } else {
Image(systemName: "book.closed.fill") Image(systemName: "book.closed.fill")
} }
Text(category.name == "*" ? String(localized: "Other") : category.name)
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default)) .font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer() Spacer()
Text("\(category.recipe_count)") Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default)) .font(.system(size: 15, weight: .bold, design: .default))
@@ -43,7 +50,6 @@ struct RecipeTabView: View {
} }
} }
} }
}
.navigationTitle("Cookbooks") .navigationTitle("Cookbooks")
.toolbar { .toolbar {
RecipeTabViewToolBar() RecipeTabViewToolBar()
@@ -100,7 +106,7 @@ struct RecipeTabView: View {
fileprivate struct RecipeTabViewToolBar: ToolbarContent { fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@EnvironmentObject var mainViewModel: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel
var body: some ToolbarContent { var body: some ToolbarContent {
@@ -111,11 +117,11 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
Task { Task {
viewModel.presentLoadingIndicator = true viewModel.presentLoadingIndicator = true
UserSettings.shared.lastUpdate = Date.distantPast UserSettings.shared.lastUpdate = Date.distantPast
await mainViewModel.getCategories() await appState.getCategories()
for category in mainViewModel.categories { for category in appState.categories {
await mainViewModel.getCategory(named: category.name, fetchMode: .preferServer) await appState.getCategory(named: category.name, fetchMode: .preferServer)
} }
await mainViewModel.updateAllRecipeDetails() await appState.updateAllRecipeDetails()
viewModel.presentLoadingIndicator = false viewModel.presentLoadingIndicator = false
} }
} label: { } label: {