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) ?? ""
}
}
@@ -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 {
@@ -14,6 +14,7 @@ struct MonthlyCheckInView: View {
@State private var editingSnapshot: Snapshot?
@State private var addingSource: InvestmentSource?
@State private var didApplyDuplicate = false
@State private var showBatchUpdate = false
@State private var showAchievementSatisfactionDialog = false
@State private var showAppStoreReviewAlert = false
@@ -55,7 +56,8 @@ struct MonthlyCheckInView: View {
private var nextCheckInDate: Date? {
guard let last = lastCompletionDate else { return nil }
return last.adding(months: checkInIntervalMonths)
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
return effective.adding(months: checkInIntervalMonths).endOfMonth
}
private var isOverdue: Bool {
@@ -148,6 +150,11 @@ struct MonthlyCheckInView: View {
.sheet(item: $addingSource) { source in
AddSnapshotView(source: source)
}
.sheet(isPresented: $showBatchUpdate) {
viewModel.refresh()
} content: {
BatchUpdateView(sources: viewModel.sources)
}
.onChange(of: starRating) { _, newValue in
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate)
}
@@ -244,6 +251,16 @@ struct MonthlyCheckInView: View {
}
}
if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
Text("Completed \(completed.friendlyDescription)")
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
let previousUnlockedAchievementKeys = unlockedAchievementKeys()
let now = Date()
@@ -260,14 +277,14 @@ struct MonthlyCheckInView: View {
showAchievementSatisfactionDialog = true
}
} label: {
Text("Mark Check-in Complete")
let isCompleted = MonthlyCheckInStore.completionDate(for: referenceDate) != nil
Text(isCompleted ? "Update Check-in" : "Mark Check-in Complete")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.appPrimary.opacity(0.1))
.background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1))
.cornerRadius(AppConstants.UI.cornerRadius)
}
.disabled(!canAddNewCheckIn)
}
.padding()
.background(Color(.systemBackground))
@@ -439,44 +456,46 @@ struct MonthlyCheckInView: View {
if perfs.count >= 2 {
let best = perfs.max(by: { $0.percentage < $1.percentage })
let worst = perfs.min(by: { $0.percentage < $1.percentage })
let bestContributor = perfs.max(by: { abs($0.diff) < abs($1.diff) })
VStack(alignment: .leading, spacing: 12) {
Text("Monthly Highlights")
.font(.headline)
if let best {
HStack {
Image(systemName: "arrow.up.circle.fill")
.foregroundColor(.positiveGreen)
VStack(alignment: .leading, spacing: 2) {
Text("Best Performer")
.font(.caption)
.foregroundColor(.secondary)
Text("\(best.name)")
.font(.subheadline.weight(.semibold))
}
Spacer()
Text(String(format: "%+.1f%%", best.percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(.positiveGreen)
}
highlightRow(
icon: "arrow.up.circle.fill",
iconColor: .positiveGreen,
label: "Best Performer",
name: best.name,
percentage: best.percentage,
diff: best.diff,
valueColor: .positiveGreen
)
}
if let worst, worst.name != best?.name {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.negativeRed)
VStack(alignment: .leading, spacing: 2) {
Text("Worst Performer")
.font(.caption)
.foregroundColor(.secondary)
Text("\(worst.name)")
.font(.subheadline.weight(.semibold))
}
Spacer()
Text(String(format: "%+.1f%%", worst.percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(.negativeRed)
highlightRow(
icon: "arrow.down.circle.fill",
iconColor: .negativeRed,
label: "Worst Performer",
name: worst.name,
percentage: worst.percentage,
diff: worst.diff,
valueColor: .negativeRed
)
}
if let contributor = bestContributor,
contributor.name != best?.name {
highlightRow(
icon: "star.circle.fill",
iconColor: .appAccent,
label: "Best Contributor",
name: contributor.name,
percentage: contributor.percentage,
diff: contributor.diff,
valueColor: .financialColor(for: contributor.diff)
)
}
}
.padding()
@@ -486,12 +505,44 @@ struct MonthlyCheckInView: View {
}
}
private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View {
HStack {
Image(systemName: icon)
.foregroundColor(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
Text(name)
.font(.subheadline.weight(.semibold))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%+.1f%%", percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(valueColor)
Text("(\(diff.compactCurrencyString))")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var sourcesCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Update Sources")
.font(.headline)
Spacer()
if viewModel.sources.count > 1 {
Button {
showBatchUpdate = true
} label: {
Label("Batch Update", systemImage: "square.and.pencil")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
Text("\(viewModel.sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
@@ -542,7 +593,7 @@ struct MonthlyCheckInView: View {
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
}
Text(latestSnapshot?.date.relativeDescription ?? String(localized: "date_never"))
Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
.font(.caption)
.foregroundColor(.secondary)
@@ -839,52 +890,201 @@ struct AchievementMilestoneBar: View {
let statuses: [MonthlyCheckInAchievementStatus]
var body: some View {
let total = max(statuses.count, 1)
let unlockedCount = statuses.filter(\.isUnlocked).count
let sorted = statuses.sorted { $0.isUnlocked && !$1.isUnlocked }
let unlockedCount = sorted.filter(\.isUnlocked).count
let total = max(sorted.count, 1)
let progress = Double(unlockedCount) / Double(total)
GeometryReader { geo in
let barWidth = geo.size.width
let barHeight: CGFloat = 6
let circleSize: CGFloat = 18
let barY = geo.size.height / 2
ZStack(alignment: .leading) {
// Background track
Capsule()
.fill(Color.gray.opacity(0.2))
.frame(height: barHeight)
.frame(width: barWidth, height: 6)
.position(x: barWidth / 2, y: barY)
// Filled track
Capsule()
.fill(Color.appSecondary)
.frame(width: barWidth * progress, height: barHeight)
.fill(Color.positiveGreen)
.frame(width: barWidth * progress, height: 6)
.position(x: barWidth * progress / 2, y: barY)
// Milestone circles
ForEach(Array(statuses.enumerated()), id: \.element.id) { index, status in
let position = total == 1
// Milestone circles on the bar
ForEach(Array(sorted.enumerated()), id: \.element.id) { index, status in
let x = total == 1
? barWidth / 2
: barWidth * Double(index) / Double(total - 1)
: circleSize / 2 + (barWidth - circleSize) * Double(index) / Double(total - 1)
ZStack {
Circle()
.fill(status.isUnlocked ? Color.appSecondary : Color.gray.opacity(0.3))
.frame(width: 14, height: 14)
.overlay(
.fill(status.isUnlocked ? Color.positiveGreen : Color.gray.opacity(0.3))
.frame(width: circleSize, height: circleSize)
Circle()
.stroke(Color(.systemBackground), lineWidth: 2)
)
.overlay(
Group {
.frame(width: circleSize, height: circleSize)
if status.isUnlocked {
Image(systemName: "checkmark")
.font(.system(size: 7, weight: .bold))
.font(.system(size: 8, weight: .bold))
.foregroundColor(.white)
}
}
.position(x: x, y: barY)
}
}
.frame(height: 24)
}
}
// MARK: - Batch Update View
struct BatchUpdateView: View {
@Environment(\.dismiss) private var dismiss
let sources: [InvestmentSource]
@State private var values: [UUID: String] = [:]
@State private var contributions: [UUID: String] = [:]
@State private var savedCount = 0
var body: some View {
NavigationStack {
List {
ForEach(sources, id: \.objectID) { source in
sourceRow(source)
}
if filledCount > 0 {
Section {
Button {
saveAll()
} label: {
Text("Save \(filledCount) Snapshot\(filledCount == 1 ? "" : "s")")
.font(.headline)
.frame(maxWidth: .infinity)
}
}
}
}
.navigationTitle("Batch Update")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { saveAll() }
.disabled(filledCount == 0)
.fontWeight(.semibold)
}
}
.onAppear {
prefillCurrentValues()
}
}
}
private func sourceRow(_ source: InvestmentSource) -> some View {
let currencyCode = source.account?.currency
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let symbol = CurrencyFormatter.symbol(for: currencyCode)
let previousValue = source.latestSnapshot?.decimalValue
let valueBinding = Binding<String>(
get: { values[source.id] ?? "" },
set: { values[source.id] = $0 }
)
.position(x: position, y: geo.size.height / 2)
let contributionBinding = Binding<String>(
get: { contributions[source.id] ?? "" },
set: { contributions[source.id] = $0 }
)
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(source.category?.color ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.subheadline.weight(.medium))
Spacer()
if let prev = previousValue {
Text(prev.currencyString)
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack {
Text(symbol)
.foregroundColor(.secondary)
TextField("Current value", text: valueBinding)
.keyboardType(.decimalPad)
}
.frame(height: 20)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(8)
HStack {
Image(systemName: "plus.circle")
.font(.caption)
.foregroundColor(.secondary)
TextField("Contribution this period (optional)", text: contributionBinding)
.keyboardType(.decimalPad)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(8)
.background(Color.appSecondary.opacity(0.06))
.cornerRadius(8)
}
.padding(.vertical, 2)
}
private var filledCount: Int {
values.values.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count
}
private func prefillCurrentValues() {
for source in sources {
guard values[source.id] == nil,
let latest = source.latestSnapshot else { continue }
let currencyCode = source.account?.currency
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
values[source.id] = CurrencyFormatter.formatForInput(latest.decimalValue, currencyCode: currencyCode)
}
}
private func saveAll() {
let repository = SnapshotRepository()
var count = 0
for source in sources {
guard let input = values[source.id],
!input.trimmingCharacters(in: .whitespaces).isEmpty else { continue }
let currencyCode = source.account?.currency
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let symbol = CurrencyFormatter.symbol(for: currencyCode)
guard let parsed = CurrencyFormatter.parseUserInput(input, currencySymbol: symbol),
parsed >= 0 else { continue }
let contributionInput = contributions[source.id] ?? ""
let parsedContribution: Decimal? = contributionInput.trimmingCharacters(in: .whitespaces).isEmpty
? nil
: CurrencyFormatter.parseUserInput(contributionInput, currencySymbol: symbol)
repository.createSnapshot(
for: source,
date: Date(),
value: parsed,
contribution: parsedContribution
)
count += 1
}
savedCount = count
dismiss()
}
}
@@ -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,7 +499,6 @@ struct AddSnapshotView: View {
}
}
if viewModel.inputMode == .detailed {
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
@@ -486,8 +514,7 @@ struct AddSnapshotView: View {
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital you've added to separate it from investment growth.")
}
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()
}
}
}
}