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
@@ -186,13 +186,31 @@ struct AddSourceView: View {
private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = string
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
guard !stripped.isEmpty else { return nil }
let decimalSep = locale.decimalSeparator ?? "."
let groupingSep = locale.groupingSeparator ?? ""
// Detect alternate decimal BEFORE removing separators
let usesAlternateDecimal =
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
if usesAlternateDecimal {
let normalized = stripped
.replacingOccurrences(of: groupingSep, with: "")
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
@@ -201,8 +219,9 @@ struct AddSourceView: View {
return value
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
.replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
@@ -402,6 +421,7 @@ struct EditSourceView: View {
struct AddSnapshotView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
let source: InvestmentSource
let snapshot: Snapshot?
@@ -444,6 +464,15 @@ struct AddSnapshotView: View {
.keyboardType(.decimalPad)
}
if let clipboardValue = viewModel.clipboardValue {
Button {
viewModel.applyClipboardValue()
} label: {
Label("Paste \(clipboardValue) from clipboard", systemImage: "doc.on.clipboard")
}
.tint(.appPrimary)
}
if viewModel.previousValue != nil {
Text(viewModel.previousValueString)
.font(.caption)
@@ -470,24 +499,22 @@ struct AddSnapshotView: View {
}
}
if viewModel.inputMode == .detailed {
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
}
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
}
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital you've added to separate it from investment growth.")
}
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital added to separate it from investment growth.")
}
Section {
@@ -514,6 +541,14 @@ struct AddSnapshotView: View {
.fontWeight(.semibold)
}
}
.onAppear {
viewModel.checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
viewModel.checkClipboard()
}
}
}
}