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
@@ -1,5 +1,6 @@
import Foundation
import Combine
import UIKit
@MainActor
class SnapshotFormViewModel: ObservableObject {
@@ -16,6 +17,8 @@ class SnapshotFormViewModel: ObservableObject {
@Published var isValid = false
@Published var errorMessage: String?
@Published var clipboardValue: String?
private var rawClipboardString: String?
// MARK: - Mode
@@ -102,53 +105,11 @@ class SnapshotFormViewModel: ObservableObject {
// MARK: - Parsing
private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let decimalSeparator = locale.decimalSeparator ?? "."
let usesAlternateDecimal =
(decimalSeparator == "," && cleaned.contains(".") && !cleaned.contains(",")) ||
(decimalSeparator == "." && cleaned.contains(",") && !cleaned.contains("."))
if usesAlternateDecimal {
let normalized = cleaned
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
if let result = formatter.number(from: cleaned)?.decimalValue {
return result
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let locale = CurrencyFormatter.locale(for: currencyCode)
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
CurrencyFormatter.formatForInput(decimal, currencyCode: currencyCode)
}
// MARK: - Computed Properties
@@ -236,6 +197,41 @@ class SnapshotFormViewModel: ObservableObject {
date = Date()
}
// MARK: - Clipboard
func checkClipboard() {
guard let raw = UIPasteboard.general.string else {
clipboardValue = nil
rawClipboardString = nil
return
}
guard let parsed = parseDecimal(raw), parsed > 0 else {
clipboardValue = nil
rawClipboardString = nil
return
}
// Don't suggest if it matches what's already typed
let formatted = formatDecimalForInput(parsed)
if formatted == valueString {
clipboardValue = nil
rawClipboardString = nil
return
}
rawClipboardString = raw
clipboardValue = CurrencyFormatter.format(parsed, style: .currency, maximumFractionDigits: 2)
}
func applyClipboardValue() {
guard let raw = rawClipboardString,
let parsed = parseDecimal(raw) else { return }
valueString = formatDecimalForInput(parsed)
clipboardValue = nil
rawClipboardString = nil
}
// MARK: - Date Validation
var isDateInFuture: Bool {