10f6d0ca20
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>
140 lines
5.6 KiB
Swift
140 lines
5.6 KiB
Swift
import Foundation
|
|
|
|
enum CurrencyFormatter {
|
|
static func currentCurrencyCode() -> String {
|
|
let context = CoreDataStack.shared.viewContext
|
|
return AppSettings.getOrCreate(in: context).currency
|
|
}
|
|
|
|
static func locale(for currencyCode: String?) -> Locale {
|
|
guard let currencyCode, !currencyCode.isEmpty else { return Locale.current }
|
|
if let match = Locale.availableIdentifiers.first(where: {
|
|
Locale(identifier: $0).currency?.identifier == currencyCode
|
|
}) {
|
|
return Locale(identifier: match)
|
|
}
|
|
return Locale.current
|
|
}
|
|
|
|
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = style
|
|
formatter.currencyCode = currentCurrencyCode()
|
|
formatter.maximumFractionDigits = maximumFractionDigits
|
|
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
|
}
|
|
|
|
static func format(
|
|
_ decimal: Decimal,
|
|
currencyCode: String?,
|
|
style: NumberFormatter.Style = .currency,
|
|
maximumFractionDigits: Int = 2,
|
|
preferredLocale: Locale? = nil
|
|
) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = style
|
|
formatter.currencyCode = currencyCode ?? currentCurrencyCode()
|
|
formatter.maximumFractionDigits = maximumFractionDigits
|
|
formatter.locale = preferredLocale ?? locale(for: currencyCode)
|
|
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
|
}
|
|
|
|
static func symbol(for code: String) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = .currency
|
|
formatter.currencyCode = code
|
|
return formatter.currencySymbol ?? code
|
|
}
|
|
|
|
/// 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: "")
|
|
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
|
|
|
|
guard !stripped.isEmpty else { return nil }
|
|
|
|
let isNegative = stripped.hasPrefix("-")
|
|
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
|
|
guard !unsigned.isEmpty else { return nil }
|
|
|
|
let normalizedUnsigned = normalizeNumericInput(unsigned)
|
|
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
|
|
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = .decimal
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
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()
|
|
formatter.numberStyle = .decimal
|
|
formatter.locale = locale(for: currencyCode)
|
|
formatter.minimumFractionDigits = 2
|
|
formatter.maximumFractionDigits = 2
|
|
formatter.groupingSeparator = ""
|
|
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
|
|
}
|
|
}
|