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