Files
InvestmentTrackerApp/PortfolioJournal/Views/Sources/AddSourceView.swift
T
alexandrev-tibco 7a18dd8360 1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad
- 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
2026-06-30 17:01:50 +02:00

684 lines
25 KiB
Swift

import SwiftUI
struct AddSourceView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var accountStore: AccountStore
@State private var name = ""
@State private var selectedCategory: Category?
@State private var initialValue = ""
@State private var showingCategoryPicker = false
@State private var selectedAccountId: UUID?
@State private var duplicateError: String?
@StateObject private var categoryRepository = CategoryRepository()
@StateObject private var sourceRepository = InvestmentSourceRepository()
private var availableAccounts: [Account] {
accountStore.accounts.filter { $0.safeId != nil }
}
var body: some View {
NavigationStack {
Form {
if availableAccounts.count > 1 {
Section {
Picker("Account", selection: $selectedAccountId) {
ForEach(availableAccounts, id: \.objectID) { account in
Text(account.name).tag(Optional(account.safeId))
}
}
.onChange(of: selectedAccountId) { _, _ in
validateSourceName(name)
}
} header: {
Text("Account")
}
}
// Source Info
Section {
TextField(String(localized: "add_source_name_placeholder"), text: $name)
.textContentType(.organizationName)
.onChange(of: name) { _, newValue in
validateSourceName(newValue)
}
Button {
showingCategoryPicker = true
} label: {
HStack {
Text("Category")
.foregroundColor(.primary)
Spacer()
if let category = selectedCategory {
HStack(spacing: 6) {
Image(systemName: category.icon)
.foregroundColor(category.color)
Text(category.name)
.foregroundColor(.secondary)
}
} else {
Text("Select")
.foregroundColor(.secondary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
} header: {
Text("Source Information")
} footer: {
if let error = duplicateError {
Text(error)
.foregroundColor(.negativeRed)
} else {
Text(String(localized: "add_source_name_footer"))
.foregroundColor(.secondary)
}
}
// Initial Value (Optional)
Section {
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
TextField("0.00", text: $initialValue)
.keyboardType(.decimalPad)
}
} header: {
Text("Initial Value (Optional)")
} footer: {
Text("You can add snapshots later if you prefer.")
}
}
.navigationTitle("Add Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add") {
saveSource()
}
.disabled(!isValid)
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showingCategoryPicker) {
CategoryPickerView(
selectedCategory: $selectedCategory,
categories: categoryRepository.categories
)
}
.onAppear {
categoryRepository.createDefaultCategoriesIfNeeded()
if selectedCategory == nil {
selectedCategory = categoryRepository.categories.first
}
if selectedAccountId == nil {
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
}
}
}
}
// MARK: - Validation
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil && duplicateError == nil
}
private func validateSourceName(_ newName: String) {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
duplicateError = nil
return
}
if sourceRepository.sourceExists(name: trimmed, in: selectedAccount) {
duplicateError = "A source with this name already exists."
} else {
duplicateError = nil
}
}
// MARK: - Save
private func saveSource() {
guard let category = selectedCategory else { return }
let repository = InvestmentSourceRepository()
let source = repository.createSource(
name: name.trimmingCharacters(in: .whitespaces),
category: category,
account: selectedAccount
)
// Add initial snapshot if provided
if let value = parseDecimal(initialValue), value > 0 {
let snapshotRepository = SnapshotRepository()
snapshotRepository.createSnapshot(
for: source,
date: Date(),
value: value
)
}
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
// Check milestone after snapshot is saved
let sourceRepo = InvestmentSourceRepository()
let allSources = sourceRepo.fetchActiveSources()
let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue }
NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: repository.sourceCount
)
dismiss()
}
private func parseDecimal(_ string: String) -> Decimal? {
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
}
private var currencySymbol: String {
if let account = selectedAccount, let code = account.currencyCode {
return CurrencyFormatter.symbol(for: code)
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
}
private var currencyCode: String {
if let account = selectedAccount, let code = account.currencyCode {
return code
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
private var selectedAccount: Account? {
guard let id = selectedAccountId else { return nil }
return availableAccounts.first { $0.safeId == id }
}
}
// MARK: - Category Picker View
struct CategoryPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedCategory: Category?
let categories: [Category]
var body: some View {
NavigationStack {
List(categories) { category in
Button {
selectedCategory = category
dismiss()
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(category.color.opacity(0.2))
.frame(width: 40, height: 40)
Image(systemName: category.icon)
.foregroundColor(category.color)
}
Text(category.name)
.foregroundColor(.primary)
Spacer()
if selectedCategory?.id == category.id {
Image(systemName: "checkmark")
.foregroundColor(.appPrimary)
}
}
}
}
.navigationTitle("Select Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// MARK: - Edit Source View
struct EditSourceView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var accountStore: AccountStore
let source: InvestmentSource
@State private var name: String
@State private var selectedCategory: Category?
@State private var showingCategoryPicker = false
@State private var selectedAccountId: UUID?
@StateObject private var categoryRepository = CategoryRepository()
init(source: InvestmentSource) {
self.source = source
_name = State(initialValue: source.name)
_selectedCategory = State(initialValue: source.category)
_selectedAccountId = State(initialValue: source.account?.safeId)
}
var body: some View {
NavigationStack {
Form {
if availableAccounts.count > 1 {
Section {
Picker("Account", selection: $selectedAccountId) {
ForEach(availableAccounts, id: \.objectID) { account in
Text(account.name).tag(Optional(account.safeId))
}
}
} header: {
Text("Account")
}
}
Section {
TextField("Source Name", text: $name)
Button {
showingCategoryPicker = true
} label: {
HStack {
Text("Category")
.foregroundColor(.primary)
Spacer()
if let category = selectedCategory {
HStack(spacing: 6) {
Image(systemName: category.icon)
.foregroundColor(category.color)
Text(category.name)
.foregroundColor(.secondary)
}
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveChanges()
}
.disabled(!isValid)
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showingCategoryPicker) {
CategoryPickerView(
selectedCategory: $selectedCategory,
categories: categoryRepository.categories
)
}
}
}
private var availableAccounts: [Account] {
accountStore.accounts.filter { $0.safeId != nil }
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
}
private func saveChanges() {
guard let category = selectedCategory else { return }
let repository = InvestmentSourceRepository()
repository.updateSource(
source,
name: name.trimmingCharacters(in: .whitespaces),
category: category,
account: selectedAccount
)
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
dismiss()
}
private var selectedAccount: Account? {
guard let id = selectedAccountId else { return nil }
return availableAccounts.first { $0.safeId == id }
}
}
// MARK: - Add Snapshot View
struct AddSnapshotView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
let source: InvestmentSource
let snapshot: Snapshot?
@StateObject private var viewModel: SnapshotFormViewModel
@State private var showingDuplicateAlert = false
@State private var showingPropagationDialog = false
@State private var pendingContribution: Decimal?
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
self.source = source
self.snapshot = snapshot
if let snapshot = snapshot {
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
} else {
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
}
}
var body: some View {
NavigationStack {
Form {
Section {
if snapshot == nil && viewModel.previousValue != nil {
Button {
viewModel.prefillFromPreviousSnapshot()
} label: {
Label("Duplicate Previous Snapshot", systemImage: "doc.on.doc")
}
}
DatePicker(
"Date",
selection: $viewModel.date,
in: ...Date(),
displayedComponents: .date
)
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
TextField("Value", text: $viewModel.valueString)
.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 {
Text(viewModel.previousValueString)
.font(.caption)
.foregroundColor(.secondary)
}
} header: {
Text("Snapshot Details")
}
// Change preview
if let change = viewModel.formattedChange,
let percentage = viewModel.formattedChangePercentage {
Section {
HStack {
Text("Change from previous")
Spacer()
Text("\(change) (\(percentage))")
.foregroundColor(
(viewModel.changeFromPrevious ?? 0) >= 0
? .positiveGreen
: .negativeRed
)
}
}
}
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
}
}
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital added to separate it from investment growth.")
}
Section {
TextField("Notes", text: $viewModel.notes, axis: .vertical)
.lineLimit(3...6)
} header: {
Text("Notes (Optional)")
}
}
.navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(viewModel.buttonTitle) {
saveSnapshot()
}
.disabled(!viewModel.isValid)
.fontWeight(.semibold)
}
}
.onAppear {
viewModel.checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
viewModel.checkClipboard()
}
}
.alert(
String(localized: "snapshot_duplicate_title"),
isPresented: $showingDuplicateAlert
) {
Button(String(localized: "snapshot_duplicate_replace"), role: .destructive) {
performSave()
}
Button(String(localized: "snapshot_duplicate_add")) {
performSave()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(String(localized: "snapshot_duplicate_message"))
}
.confirmationDialog(
String(localized: "snapshot_contribution_propagate_title"),
isPresented: $showingPropagationDialog,
titleVisibility: .visible
) {
Button(String(localized: "snapshot_contribution_propagate_forward")) { propagate(.forward) }
Button(String(localized: "snapshot_contribution_propagate_backward")) { propagate(.backward) }
Button(String(localized: "snapshot_contribution_propagate_all")) { propagate(.all) }
Button(String(localized: "snapshot_contribution_propagate_this"), role: .cancel) { dismiss() }
} message: {
if let amount = pendingContribution {
Text(String(format: String(localized: "snapshot_contribution_propagate_message"), amount.currencyString))
}
}
}
}
private func saveSnapshot() {
guard snapshot == nil else {
performSave()
return
}
// Check for existing snapshot in the same month
let calendar = Calendar.current
let selectedMonth = calendar.dateComponents([.year, .month], from: viewModel.date)
let repo = SnapshotRepository()
let existing = repo.fetchSnapshots(for: source, limitedToMonths: nil)
let hasDuplicate = existing.contains { snap in
let snapMonth = calendar.dateComponents([.year, .month], from: snap.date)
return snapMonth == selectedMonth
}
if hasDuplicate {
showingDuplicateAlert = true
} else {
performSave()
}
}
private func performSave() {
guard let value = viewModel.value else { return }
let repository = SnapshotRepository()
let isEdit = snapshot != nil
if let snapshot = snapshot {
let contributionTextIsEmpty = viewModel.contributionString
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
repository.updateSnapshot(
snapshot,
date: viewModel.date,
value: value,
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
clearContribution: !viewModel.includeContribution || contributionTextIsEmpty,
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
} else {
// Capture pre-save goal state for achievement detection
let goalRepo = GoalRepository()
let sourceRepo = InvestmentSourceRepository()
let prevAchievedIds = goalsAchievedBeforeSave(goalRepo: goalRepo, sourceRepo: sourceRepo)
repository.createSnapshot(
for: source,
date: viewModel.date,
value: value,
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
)
// Check if any goals became achieved
checkGoalAchievements(goalRepo: goalRepo, sourceRepo: sourceRepo, prevAchievedIds: prevAchievedIds)
}
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
// When editing, offer to propagate a changed contribution to other snapshots.
if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged {
pendingContribution = contribution
showingPropagationDialog = true
return
}
dismiss()
}
private func propagate(_ direction: SnapshotRepository.ContributionPropagation) {
if let snapshot = snapshot, let amount = pendingContribution {
SnapshotRepository().propagateContribution(amount, from: snapshot, direction: direction)
}
dismiss()
}
private func goalsAchievedBeforeSave(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository) -> Set<UUID> {
let goals = goalRepo.goals.filter { $0.isActive }
var achieved = Set<UUID>()
for goal in goals {
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
achieved.insert(goal.id)
}
}
return achieved
}
private func checkGoalAchievements(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository, prevAchievedIds: Set<UUID>) {
// Reload sources to pick up new snapshot value
let goals = goalRepo.goals.filter { $0.isActive }
for goal in goals {
guard !prevAchievedIds.contains(goal.id) else { continue }
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
}
}
}
}
#Preview {
AddSourceView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}