Release 1.2.1: iCloud sync improvements + ASO multilingual metadata
iCloud sync: - Force viewContext.refreshAllObjects() on remote change notifications so data from other devices is picked up immediately without app restart - Call refreshFromCloudKit() on foreground to merge any changes made while the device was inactive - Wait up to 10s for initial CloudKit sync on launch before showing onboarding (shows "Checking iCloud..." during the wait) - New OnboardingICloudCheckView: shown on fresh installs with iCloud available, lets user restore from iCloud before starting onboarding from scratch Localization: - Added de, fr, it, ja, pt-BR lproj folders - New iCloud onboarding strings in en + es-ES (+ button literals) ASO metadata (fastlane): - Updated en-US: new subtitle, keywords, description (fixed "no cloud sync" claim), promotional text, release notes - Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total) - All keyword fields validated ≤100 Unicode chars Infrastructure: - Gemfile + Gemfile.lock for fastlane - Scripts/archive_and_upload_appstore.sh for CI/CD Version: 1.2.1 (build 7) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,58 +46,23 @@ enum CurrencyFormatter {
|
||||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
|
||||
/// Parses a user-typed numeric string using digit-position-based separator detection.
|
||||
/// Locale-independent: a separator followed by 1-2 digits at the end is decimal,
|
||||
/// otherwise it is a thousands separator.
|
||||
/// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators.
|
||||
/// The parser is intentionally permissive to avoid turning decimal input into huge integers.
|
||||
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
|
||||
let stripped = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "," }
|
||||
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
|
||||
|
||||
guard !stripped.isEmpty else { return nil }
|
||||
|
||||
let hasDot = stripped.contains(".")
|
||||
let hasComma = stripped.contains(",")
|
||||
let isNegative = stripped.hasPrefix("-")
|
||||
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
|
||||
guard !unsigned.isEmpty else { return nil }
|
||||
|
||||
let normalized: String
|
||||
|
||||
if hasDot && hasComma {
|
||||
// Both separators: the one appearing last is the decimal
|
||||
let lastDot = stripped.lastIndex(of: ".")!
|
||||
let lastComma = stripped.lastIndex(of: ",")!
|
||||
if lastComma > lastDot {
|
||||
// e.g. 1.234,56 → 1234.56
|
||||
normalized = stripped
|
||||
.replacingOccurrences(of: ".", with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
} else {
|
||||
// e.g. 1,234.56 → 1234.56
|
||||
normalized = stripped.replacingOccurrences(of: ",", with: "")
|
||||
}
|
||||
} else if hasComma {
|
||||
// Only comma: decimal if exactly 2 parts and last has ≤2 digits
|
||||
let parts = stripped.components(separatedBy: ",")
|
||||
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
|
||||
// e.g. 408857,62 → 408857.62
|
||||
normalized = stripped.replacingOccurrences(of: ",", with: ".")
|
||||
} else {
|
||||
// e.g. 408,857 or 1,234,567 → thousands
|
||||
normalized = stripped.replacingOccurrences(of: ",", with: "")
|
||||
}
|
||||
} else if hasDot {
|
||||
// Only dot: decimal if exactly 2 parts and last has ≤2 digits
|
||||
let parts = stripped.components(separatedBy: ".")
|
||||
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
|
||||
// e.g. 408857.62 → already correct
|
||||
normalized = stripped
|
||||
} else {
|
||||
// e.g. 408.857 or 1.234.567 → thousands
|
||||
normalized = stripped.replacingOccurrences(of: ".", with: "")
|
||||
}
|
||||
} else {
|
||||
normalized = stripped
|
||||
}
|
||||
let normalizedUnsigned = normalizeNumericInput(unsigned)
|
||||
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
@@ -105,6 +70,62 @@ enum CurrencyFormatter {
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
private static func normalizeNumericInput(_ input: String) -> String {
|
||||
let hasDot = input.contains(".")
|
||||
let hasComma = input.contains(",")
|
||||
|
||||
if hasDot && hasComma {
|
||||
// Both separators present: the last one is considered decimal.
|
||||
let lastDot = input.lastIndex(of: ".")!
|
||||
let lastComma = input.lastIndex(of: ",")!
|
||||
if lastComma > lastDot {
|
||||
return input
|
||||
.replacingOccurrences(of: ".", with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
}
|
||||
return input.replacingOccurrences(of: ",", with: "")
|
||||
}
|
||||
|
||||
if hasDot {
|
||||
return normalizeSingleSeparator(input, separator: ".")
|
||||
}
|
||||
|
||||
if hasComma {
|
||||
return normalizeSingleSeparator(input, separator: ",")
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String {
|
||||
let parts = input.split(separator: separator, omittingEmptySubsequences: false)
|
||||
guard parts.count > 1 else { return input }
|
||||
|
||||
if parts.count == 2 {
|
||||
// A single separator is treated as decimal (e.g. 533.595).
|
||||
return "\(parts[0]).\(parts[1])"
|
||||
}
|
||||
|
||||
let integerParts = Array(parts.dropLast())
|
||||
let fractionPart = String(parts.last ?? "")
|
||||
|
||||
// If all groups follow a strict thousands pattern and the last one has 3 digits,
|
||||
// prefer grouping-only interpretation (e.g. 1.234.567).
|
||||
if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 {
|
||||
return input.replacingOccurrences(of: String(separator), with: "")
|
||||
}
|
||||
|
||||
// Otherwise, treat the last separator as decimal and previous ones as grouping.
|
||||
let integer = integerParts.joined()
|
||||
return "\(integer).\(fractionPart)"
|
||||
}
|
||||
|
||||
private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool {
|
||||
guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false }
|
||||
guard groups.count >= 2 else { return false }
|
||||
return groups.dropFirst().allSatisfy { $0.count == 3 }
|
||||
}
|
||||
|
||||
/// Formats a decimal for display in an input field (no grouping separator).
|
||||
static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
|
||||
Reference in New Issue
Block a user