@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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" */;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<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 []
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -27,4 +27,7 @@ extension Color {
|
||||
public static var ncGradientLight: Color {
|
||||
return Color("ncgradientlightblue")
|
||||
}
|
||||
public static var ncTextHighlight: Color {
|
||||
return Color("textHighlight")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
22
Nextcloud Cookbook iOS Client/Util/NumberFormatter.swift
Normal file
22
Nextcloud Cookbook iOS Client/Util/NumberFormatter.swift
Normal 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()
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ struct RecipeListView: View {
|
||||
@State var selectedRecipe: Recipe? = nil
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
let recipes = recipesFiltered()
|
||||
if !recipes.isEmpty {
|
||||
List(recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
@@ -34,6 +37,22 @@ struct RecipeListView: View {
|
||||
.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(.bordered)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
||||
|
||||
|
||||
@@ -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..<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(
|
||||
viewModel.observableRecipeDetail.recipeIngredient[ix],
|
||||
toRecipe: viewModel.observableRecipeDetail.id,
|
||||
@@ -56,6 +63,16 @@ struct RecipeIngredientSection: View {
|
||||
}
|
||||
.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 {
|
||||
Button {
|
||||
viewModel.presentIngredientEditView.toggle()
|
||||
@@ -64,7 +81,9 @@ struct RecipeIngredientSection: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
.padding()
|
||||
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +92,16 @@ struct RecipeIngredientSection: View {
|
||||
fileprivate struct IngredientListItem: View {
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@Binding var ingredient: String
|
||||
@Binding var servings: Double
|
||||
@State var recipeYield: Double
|
||||
@State var recipeId: String
|
||||
let addToGroceryListAction: () -> 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)")
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,16 +19,23 @@ 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 {
|
||||
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)
|
||||
|
||||
if category.name == "*" {
|
||||
Text("Other")
|
||||
.font(.system(size: 20, weight: .medium, design: .default))
|
||||
} 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))
|
||||
@@ -43,7 +50,6 @@ struct RecipeTabView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Cookbooks")
|
||||
.toolbar {
|
||||
RecipeTabViewToolBar()
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user