Fix decimal parsing bug + contribution and pre-fill features
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>
This commit is contained in:
@@ -45,4 +45,74 @@ enum CurrencyFormatter {
|
||||
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) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user