7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
265 lines
7.9 KiB
Swift
265 lines
7.9 KiB
Swift
import Foundation
|
|
import Combine
|
|
import UIKit
|
|
|
|
@MainActor
|
|
class SnapshotFormViewModel: ObservableObject {
|
|
// MARK: - Published Properties
|
|
|
|
@Published var date = Date()
|
|
@Published var valueString = ""
|
|
@Published var contributionString = ""
|
|
@Published var notes = ""
|
|
@Published var includeContribution = false
|
|
@Published var inputMode: InputMode = .simple
|
|
@Published var currencySymbol = "€"
|
|
private let currencyCode: String
|
|
|
|
@Published var isValid = false
|
|
@Published var errorMessage: String?
|
|
@Published var clipboardValue: String?
|
|
private var rawClipboardString: String?
|
|
|
|
// MARK: - Mode
|
|
|
|
enum Mode {
|
|
case add
|
|
case edit(Snapshot)
|
|
}
|
|
|
|
let mode: Mode
|
|
let source: InvestmentSource
|
|
|
|
/// Contribution stored on the snapshot when editing started (nil in add mode or
|
|
/// when the snapshot had no contribution). Used to detect whether the user changed
|
|
/// the contribution and should be offered to propagate it to other snapshots.
|
|
private(set) var originalContribution: Decimal?
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(source: InvestmentSource, mode: Mode = .add) {
|
|
self.source = source
|
|
self.mode = mode
|
|
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
|
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
|
|
currencyCode = accountCurrency
|
|
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
|
|
} else {
|
|
currencyCode = settings.currency
|
|
currencySymbol = settings.currencySymbol
|
|
}
|
|
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
|
|
inputMode = accountMode
|
|
} else {
|
|
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
|
}
|
|
includeContribution = inputMode == .detailed
|
|
|
|
setupValidation()
|
|
|
|
// Pre-fill for edit mode
|
|
if case .edit(let snapshot) = mode {
|
|
date = snapshot.date
|
|
valueString = formatDecimalForInput(snapshot.decimalValue)
|
|
if let contribution = snapshot.contribution {
|
|
includeContribution = true
|
|
contributionString = formatDecimalForInput(contribution.decimalValue)
|
|
originalContribution = contribution.decimalValue
|
|
}
|
|
notes = snapshot.notes ?? ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
private func setupValidation() {
|
|
Publishers.CombineLatest3($valueString, $contributionString, $includeContribution)
|
|
.map { [weak self] valueStr, contribStr, includeContrib in
|
|
self?.validateInputs(
|
|
valueString: valueStr,
|
|
contributionString: contribStr,
|
|
includeContribution: includeContrib
|
|
) ?? false
|
|
}
|
|
.assign(to: &$isValid)
|
|
}
|
|
|
|
private func validateInputs(
|
|
valueString: String,
|
|
contributionString: String,
|
|
includeContribution: Bool
|
|
) -> Bool {
|
|
// Value is required
|
|
guard let value = parseDecimal(valueString), value >= 0 else {
|
|
return false
|
|
}
|
|
|
|
// Contribution is optional but must be valid if included
|
|
if includeContribution {
|
|
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty {
|
|
guard let contribution = parseDecimal(trimmed), contribution >= 0 else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
private func parseDecimal(_ string: String) -> Decimal? {
|
|
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
|
|
}
|
|
|
|
private func formatDecimalForInput(_ decimal: Decimal) -> String {
|
|
CurrencyFormatter.formatForInput(decimal, currencyCode: currencyCode)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var value: Decimal? {
|
|
parseDecimal(valueString)
|
|
}
|
|
|
|
var contribution: Decimal? {
|
|
guard includeContribution else { return nil }
|
|
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return parseDecimal(trimmed)
|
|
}
|
|
|
|
/// True when the current contribution differs from the value the snapshot had when
|
|
/// editing started. Drives the "propagate to other snapshots" prompt.
|
|
var contributionChanged: Bool {
|
|
contribution != originalContribution
|
|
}
|
|
|
|
var formattedValue: String {
|
|
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
|
|
return value.currencyString
|
|
}
|
|
|
|
var title: String {
|
|
switch mode {
|
|
case .add:
|
|
return "Add Snapshot"
|
|
case .edit:
|
|
return "Edit Snapshot"
|
|
}
|
|
}
|
|
|
|
var buttonTitle: String {
|
|
switch mode {
|
|
case .add:
|
|
return "Add Snapshot"
|
|
case .edit:
|
|
return "Save Changes"
|
|
}
|
|
}
|
|
|
|
// MARK: - Previous Value Reference
|
|
|
|
var previousValue: Decimal? {
|
|
source.latestSnapshot?.decimalValue
|
|
}
|
|
|
|
var previousValueString: String {
|
|
guard let previous = previousValue else { return "No previous value" }
|
|
return "Previous: \(previous.currencyString)"
|
|
}
|
|
|
|
var changeFromPrevious: Decimal? {
|
|
guard let current = value, let previous = previousValue else { return nil }
|
|
return current - previous
|
|
}
|
|
|
|
var changePercentageFromPrevious: Double? {
|
|
guard let current = value,
|
|
let previous = previousValue,
|
|
previous != 0 else { return nil }
|
|
|
|
return NSDecimalNumber(decimal: (current - previous) / previous).doubleValue * 100
|
|
}
|
|
|
|
var formattedChange: String? {
|
|
guard let change = changeFromPrevious else { return nil }
|
|
let prefix = change >= 0 ? "+" : ""
|
|
return "\(prefix)\(change.currencyString)"
|
|
}
|
|
|
|
var formattedChangePercentage: String? {
|
|
guard let percentage = changePercentageFromPrevious else { return nil }
|
|
let prefix = percentage >= 0 ? "+" : ""
|
|
return String(format: "\(prefix)%.2f%%", percentage)
|
|
}
|
|
|
|
// MARK: - Duplicate Previous
|
|
|
|
func prefillFromPreviousSnapshot() {
|
|
guard case .add = mode,
|
|
let previous = source.latestSnapshot else { return }
|
|
|
|
valueString = formatDecimalForInput(previous.decimalValue)
|
|
if let contribution = previous.contribution {
|
|
includeContribution = true
|
|
contributionString = formatDecimalForInput(contribution.decimalValue)
|
|
}
|
|
notes = previous.notes ?? ""
|
|
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 {
|
|
date > Date()
|
|
}
|
|
|
|
var dateWarning: String? {
|
|
if isDateInFuture {
|
|
return "Date is in the future"
|
|
}
|
|
return nil
|
|
}
|
|
}
|