From ce4bbd9676fc479bd1154fea00f91d0a2a33f7c0 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 20 Feb 2026 22:29:26 +0100 Subject: [PATCH] Fix decimal parsing bug + contribution and pre-fill features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Utilities/CurrencyFormatter.swift | 70 ++++ .../ViewModels/SnapshotFormViewModel.swift | 84 +++-- .../Views/Dashboard/MonthlyCheckInView.swift | 338 ++++++++++++++---- .../Views/Sources/AddSourceView.swift | 71 +++- 4 files changed, 432 insertions(+), 131 deletions(-) diff --git a/PortfolioJournal/Utilities/CurrencyFormatter.swift b/PortfolioJournal/Utilities/CurrencyFormatter.swift index dd1587e..cf00d02 100644 --- a/PortfolioJournal/Utilities/CurrencyFormatter.swift +++ b/PortfolioJournal/Utilities/CurrencyFormatter.swift @@ -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) ?? "" + } } diff --git a/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift index 2a3db6f..ba53191 100644 --- a/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift +++ b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift @@ -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 { diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index 0c45d6d..7e75793 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -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( + get: { values[source.id] ?? "" }, + set: { values[source.id] = $0 } + ) + let contributionBinding = Binding( + 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() } } diff --git a/PortfolioJournal/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift index fa77bf9..23b5525 100644 --- a/PortfolioJournal/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -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() + } + } } }