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
@@ -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)
// Background track
Capsule()
.fill(Color.gray.opacity(0.2))
.frame(width: barWidth, height: 6)
.position(x: barWidth / 2, y: barY)
// Filled track
Capsule()
.fill(Color.appSecondary)
.frame(width: barWidth * progress, height: barHeight)
// Filled track
Capsule()
.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
? barWidth / 2
: barWidth * Double(index) / Double(total - 1)
// Milestone circles on the bar
ForEach(Array(sorted.enumerated()), id: \.element.id) { index, status in
let x = total == 1
? barWidth / 2
: 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(
Circle()
.stroke(Color(.systemBackground), lineWidth: 2)
)
.overlay(
Group {
if status.isUnlocked {
Image(systemName: "checkmark")
.font(.system(size: 7, weight: .bold))
.foregroundColor(.white)
}
}
)
.position(x: position, y: geo.size.height / 2)
.fill(status.isUnlocked ? Color.positiveGreen : Color.gray.opacity(0.3))
.frame(width: circleSize, height: circleSize)
Circle()
.stroke(Color(.systemBackground), lineWidth: 2)
.frame(width: circleSize, height: circleSize)
if status.isUnlocked {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.white)
}
}
.position(x: x, y: barY)
}
}
.frame(height: 20)
.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 }
)
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)
}
.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,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()
}
}
}
}