ce4bbd9676
BUG FIX: parseDecimal ignored locale mismatch between currency and device. CurrencyFormatter.locale(for: "EUR") could return any EUR locale (e.g. en_IE with decimal='.'), causing "408857,62" to be parsed as 40,885,762. New CurrencyFormatter.parseUserInput() uses digit-position detection: a separator followed by ≤2 digits at the end is decimal, otherwise it is a thousands separator. Fully locale-independent. FEATURE: Contribution field now always visible in AddSnapshotView. Previously gated behind inputMode == .detailed; now a toggle available regardless of account mode. FEATURE: BatchUpdateView pre-fills each text field with the source's current value so the user only edits what changed. Adds an optional contribution field per source row, persisted on save. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
4.9 KiB
Swift
119 lines
4.9 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 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.
|
|
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
|
|
let stripped = string
|
|
.replacingOccurrences(of: currencySymbol, with: "")
|
|
.trimmingCharacters(in: .whitespaces)
|
|
.filter { $0.isNumber || $0 == "." || $0 == "," }
|
|
|
|
guard !stripped.isEmpty else { return nil }
|
|
|
|
let hasDot = stripped.contains(".")
|
|
let hasComma = stripped.contains(",")
|
|
|
|
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 formatter = NumberFormatter()
|
|
formatter.numberStyle = .decimal
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
return formatter.number(from: normalized)?.decimalValue
|
|
}
|
|
|
|
/// 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) ?? ""
|
|
}
|
|
}
|