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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user