Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift
T
alexandrev-tibco 10f6d0ca20 Release 1.2.1: iCloud sync improvements + ASO multilingual metadata
iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:54:35 +01:00

253 lines
7.3 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
// 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)
}
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)
}
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
}
}