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