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:
alexandrev-tibco
2026-02-20 22:29:26 +01:00
parent bc159507c8
commit ce4bbd9676
4 changed files with 432 additions and 131 deletions
@@ -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) ?? ""
}
}