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 formatter.currencyCode = code
return formatter.currencySymbol ?? 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 Foundation
import Combine import Combine
import UIKit
@MainActor @MainActor
class SnapshotFormViewModel: ObservableObject { class SnapshotFormViewModel: ObservableObject {
@@ -16,6 +17,8 @@ class SnapshotFormViewModel: ObservableObject {
@Published var isValid = false @Published var isValid = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var clipboardValue: String?
private var rawClipboardString: String?
// MARK: - Mode // MARK: - Mode
@@ -102,53 +105,11 @@ class SnapshotFormViewModel: ObservableObject {
// MARK: - Parsing // MARK: - Parsing
private func parseDecimal(_ string: String) -> Decimal? { private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode) CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
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
} }
private func formatDecimalForInput(_ decimal: Decimal) -> String { private func formatDecimalForInput(_ decimal: Decimal) -> String {
let locale = CurrencyFormatter.locale(for: currencyCode) CurrencyFormatter.formatForInput(decimal, currencyCode: 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) ?? ""
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@@ -236,6 +197,41 @@ class SnapshotFormViewModel: ObservableObject {
date = Date() 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 // MARK: - Date Validation
var isDateInFuture: Bool { var isDateInFuture: Bool {
@@ -14,6 +14,7 @@ struct MonthlyCheckInView: View {
@State private var editingSnapshot: Snapshot? @State private var editingSnapshot: Snapshot?
@State private var addingSource: InvestmentSource? @State private var addingSource: InvestmentSource?
@State private var didApplyDuplicate = false @State private var didApplyDuplicate = false
@State private var showBatchUpdate = false
@State private var showAchievementSatisfactionDialog = false @State private var showAchievementSatisfactionDialog = false
@State private var showAppStoreReviewAlert = false @State private var showAppStoreReviewAlert = false
@@ -55,7 +56,8 @@ struct MonthlyCheckInView: View {
private var nextCheckInDate: Date? { private var nextCheckInDate: Date? {
guard let last = lastCompletionDate else { return nil } 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 { private var isOverdue: Bool {
@@ -148,6 +150,11 @@ struct MonthlyCheckInView: View {
.sheet(item: $addingSource) { source in .sheet(item: $addingSource) { source in
AddSnapshotView(source: source) AddSnapshotView(source: source)
} }
.sheet(isPresented: $showBatchUpdate) {
viewModel.refresh()
} content: {
BatchUpdateView(sources: viewModel.sources)
}
.onChange(of: starRating) { _, newValue in .onChange(of: starRating) { _, newValue in
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate) 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 { Button {
let previousUnlockedAchievementKeys = unlockedAchievementKeys() let previousUnlockedAchievementKeys = unlockedAchievementKeys()
let now = Date() let now = Date()
@@ -260,14 +277,14 @@ struct MonthlyCheckInView: View {
showAchievementSatisfactionDialog = true showAchievementSatisfactionDialog = true
} }
} label: { } 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)) .font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 10) .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) .cornerRadius(AppConstants.UI.cornerRadius)
} }
.disabled(!canAddNewCheckIn)
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color(.systemBackground))
@@ -439,44 +456,46 @@ struct MonthlyCheckInView: View {
if perfs.count >= 2 { if perfs.count >= 2 {
let best = perfs.max(by: { $0.percentage < $1.percentage }) let best = perfs.max(by: { $0.percentage < $1.percentage })
let worst = perfs.min(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) { VStack(alignment: .leading, spacing: 12) {
Text("Monthly Highlights") Text("Monthly Highlights")
.font(.headline) .font(.headline)
if let best { if let best {
HStack { highlightRow(
Image(systemName: "arrow.up.circle.fill") icon: "arrow.up.circle.fill",
.foregroundColor(.positiveGreen) iconColor: .positiveGreen,
VStack(alignment: .leading, spacing: 2) { label: "Best Performer",
Text("Best Performer") name: best.name,
.font(.caption) percentage: best.percentage,
.foregroundColor(.secondary) diff: best.diff,
Text("\(best.name)") valueColor: .positiveGreen
.font(.subheadline.weight(.semibold)) )
}
Spacer()
Text(String(format: "%+.1f%%", best.percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(.positiveGreen)
}
} }
if let worst, worst.name != best?.name { if let worst, worst.name != best?.name {
HStack { highlightRow(
Image(systemName: "arrow.down.circle.fill") icon: "arrow.down.circle.fill",
.foregroundColor(.negativeRed) iconColor: .negativeRed,
VStack(alignment: .leading, spacing: 2) { label: "Worst Performer",
Text("Worst Performer") name: worst.name,
.font(.caption) percentage: worst.percentage,
.foregroundColor(.secondary) diff: worst.diff,
Text("\(worst.name)") valueColor: .negativeRed
.font(.subheadline.weight(.semibold)) )
} }
Spacer()
Text(String(format: "%+.1f%%", worst.percentage)) if let contributor = bestContributor,
.font(.subheadline.weight(.bold)) contributor.name != best?.name {
.foregroundColor(.negativeRed) 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() .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 { private var sourcesCard: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
Text("Update Sources") Text("Update Sources")
.font(.headline) .font(.headline)
Spacer() 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)") Text("\(viewModel.sources.count)")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -542,7 +593,7 @@ struct MonthlyCheckInView: View {
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed) .foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
} }
Text(latestSnapshot?.date.relativeDescription ?? String(localized: "date_never")) Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -839,52 +890,201 @@ struct AchievementMilestoneBar: View {
let statuses: [MonthlyCheckInAchievementStatus] let statuses: [MonthlyCheckInAchievementStatus]
var body: some View { var body: some View {
let total = max(statuses.count, 1) let sorted = statuses.sorted { $0.isUnlocked && !$1.isUnlocked }
let unlockedCount = statuses.filter(\.isUnlocked).count let unlockedCount = sorted.filter(\.isUnlocked).count
let total = max(sorted.count, 1)
let progress = Double(unlockedCount) / Double(total) let progress = Double(unlockedCount) / Double(total)
GeometryReader { geo in GeometryReader { geo in
let barWidth = geo.size.width let barWidth = geo.size.width
let barHeight: CGFloat = 6 let circleSize: CGFloat = 18
let barY = geo.size.height / 2
ZStack(alignment: .leading) { // Background track
// Background track Capsule()
Capsule() .fill(Color.gray.opacity(0.2))
.fill(Color.gray.opacity(0.2)) .frame(width: barWidth, height: 6)
.frame(height: barHeight) .position(x: barWidth / 2, y: barY)
// Filled track // Filled track
Capsule() Capsule()
.fill(Color.appSecondary) .fill(Color.positiveGreen)
.frame(width: barWidth * progress, height: barHeight) .frame(width: barWidth * progress, height: 6)
.position(x: barWidth * progress / 2, y: barY)
// Milestone circles // Milestone circles on the bar
ForEach(Array(statuses.enumerated()), id: \.element.id) { index, status in ForEach(Array(sorted.enumerated()), id: \.element.id) { index, status in
let position = total == 1 let x = total == 1
? barWidth / 2 ? barWidth / 2
: barWidth * Double(index) / Double(total - 1) : circleSize / 2 + (barWidth - circleSize) * Double(index) / Double(total - 1)
ZStack {
Circle() Circle()
.fill(status.isUnlocked ? Color.appSecondary : Color.gray.opacity(0.3)) .fill(status.isUnlocked ? Color.positiveGreen : Color.gray.opacity(0.3))
.frame(width: 14, height: 14) .frame(width: circleSize, height: circleSize)
.overlay( Circle()
Circle() .stroke(Color(.systemBackground), lineWidth: 2)
.stroke(Color(.systemBackground), lineWidth: 2) .frame(width: circleSize, height: circleSize)
)
.overlay( if status.isUnlocked {
Group { Image(systemName: "checkmark")
if status.isUnlocked { .font(.system(size: 8, weight: .bold))
Image(systemName: "checkmark") .foregroundColor(.white)
.font(.system(size: 7, weight: .bold)) }
.foregroundColor(.white)
}
}
)
.position(x: position, y: geo.size.height / 2)
} }
.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? { private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode) let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = string let stripped = string
.replacingOccurrences(of: currencySymbol, with: "") .replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces) .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() let formatter = NumberFormatter()
formatter.numberStyle = .decimal formatter.numberStyle = .decimal
formatter.locale = locale formatter.locale = locale
@@ -201,8 +219,9 @@ struct AddSourceView: View {
return value return value
} }
// Fallback for mixed locale input
let normalized = cleaned let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".") .replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".") .replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue return formatter.number(from: normalized)?.decimalValue
@@ -402,6 +421,7 @@ struct EditSourceView: View {
struct AddSnapshotView: View { struct AddSnapshotView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
let source: InvestmentSource let source: InvestmentSource
let snapshot: Snapshot? let snapshot: Snapshot?
@@ -444,6 +464,15 @@ struct AddSnapshotView: View {
.keyboardType(.decimalPad) .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 { if viewModel.previousValue != nil {
Text(viewModel.previousValueString) Text(viewModel.previousValueString)
.font(.caption) .font(.caption)
@@ -470,24 +499,22 @@ struct AddSnapshotView: View {
} }
} }
if viewModel.inputMode == .detailed { Section {
Section { Toggle("Include Contribution", isOn: $viewModel.includeContribution)
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
if viewModel.includeContribution { if viewModel.includeContribution {
HStack { HStack {
Text(viewModel.currencySymbol) Text(viewModel.currencySymbol)
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("New capital added", text: $viewModel.contributionString) TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad) .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 { Section {
@@ -514,6 +541,14 @@ struct AddSnapshotView: View {
.fontWeight(.semibold) .fontWeight(.semibold)
} }
} }
.onAppear {
viewModel.checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
viewModel.checkClipboard()
}
}
} }
} }