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