Files
InvestmentTrackerApp/PortfolioJournal/Views/Sources/SourceDetailView.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

628 lines
23 KiB
Swift

import SwiftUI
import CoreData
import Charts
struct SourceDetailView: View {
@StateObject private var viewModel: SourceDetailViewModel
@Environment(\.dismiss) private var dismiss
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@State private var showingDeleteConfirmation = false
@Environment(\.managedObjectContext) private var viewContext
@State private var editingSnapshotSelection: SnapshotSelection?
@State private var editingContribution = false
@State private var contributionInput = ""
@State private var showingContributionApplyDialog = false
@State private var pendingContributionAmount: Decimal? = nil
init(source: InvestmentSource, iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
source: source,
iapService: iapService
))
}
var body: some View {
Group {
if viewModel.isDeleted {
Color.clear
.onAppear {
dismiss()
}
} else {
contentView
}
}
}
private var contentView: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
// Header Card
headerCard
// Performance Summary full width, above quick actions
if calmModeEnabled {
simpleMetricsSection
} else {
metricsSection
}
// Quick Actions
quickActions
// Monthly Contribution
monthlyContributionCard
// Chart
if !viewModel.chartData.isEmpty {
chartSection
}
// Snapshots List
snapshotsSection
}
.padding()
}
}
.navigationTitle(viewModel.safeSourceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
viewModel.showingEditSource = true
} label: {
Label("Edit Source", systemImage: "pencil")
}
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label("Delete Source", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $viewModel.showingAddSnapshot) {
AddSnapshotView(source: viewModel.source)
}
.sheet(item: $editingSnapshotSelection) { selection in
if let snapshot = try? viewContext.existingObject(with: selection.id) as? Snapshot {
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
} else {
MissingSnapshotView()
}
}
.sheet(isPresented: $viewModel.showingEditSource) {
EditSourceView(source: viewModel.source)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.confirmationDialog(
"Delete Source",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
viewModel.deleteSource()
dismiss()
}
} message: {
Text("This will permanently delete \(viewModel.safeSourceName) and all its snapshots.")
}
.onChange(of: viewModel.isDeleted) { _, deleted in
if deleted {
dismiss()
}
}
}
// MARK: - Header Card
private var headerCard: some View {
VStack(spacing: 12) {
// Category badge
HStack {
Image(systemName: viewModel.source.category?.icon ?? "questionmark")
Text(viewModel.categoryName)
}
.font(.caption)
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
.cornerRadius(20)
// Current value
Text(viewModel.formattedCurrentValue)
.font(.system(size: 36, weight: .bold, design: .rounded))
// Return
if calmModeEnabled {
VStack(spacing: 2) {
Text("All-time return")
.font(.caption)
.foregroundColor(.secondary)
Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
}
} else {
HStack(spacing: 4) {
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
Text(viewModel.formattedTotalReturn)
Text("(\(viewModel.formattedPercentageReturn))")
}
.font(.subheadline.weight(.medium))
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
}
// Last updated
Text("Last updated: \(viewModel.lastUpdated)")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Quick Actions
private var quickActions: some View {
Button {
viewModel.showingAddSnapshot = true
} label: {
Label("Add Snapshot", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
// MARK: - Monthly Contribution Card
private var monthlyContributionCard: some View {
let sourceId = viewModel.source.id
let current = MonthlyContributionStore.contribution(for: sourceId)
return VStack(alignment: .leading, spacing: 10) {
HStack {
Label(String(localized: "source_monthly_contribution_title"), systemImage: "arrow.down.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Spacer()
Button {
if editingContribution {
if let val = CurrencyFormatter.parseUserInput(contributionInput), val > 0 {
MonthlyContributionStore.setContribution(val, for: sourceId)
pendingContributionAmount = val
showingContributionApplyDialog = true
} else if contributionInput.trimmingCharacters(in: .whitespaces).isEmpty {
MonthlyContributionStore.setContribution(nil, for: sourceId)
}
} else {
contributionInput = current.map { String(format: "%.2f", NSDecimalNumber(decimal: $0).doubleValue) } ?? ""
}
editingContribution.toggle()
} label: {
Text(editingContribution ? String(localized: "done") : String(localized: "edit"))
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
if editingContribution {
TextField(String(localized: "source_monthly_contribution_placeholder"), text: $contributionInput)
.keyboardType(.decimalPad)
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(8)
} else {
Text(current.map { $0.currencyString } ?? String(localized: "source_monthly_contribution_not_set"))
.font(.subheadline)
.foregroundColor(current != nil ? .primary : .secondary)
}
Text(String(localized: "source_monthly_contribution_hint"))
.font(.caption2)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.confirmationDialog(
String(localized: "source_monthly_contribution_apply_title"),
isPresented: $showingContributionApplyDialog,
titleVisibility: .visible
) {
Button(String(localized: "source_monthly_contribution_apply_retroactive")) {
if let amount = pendingContributionAmount {
applyContributionRetroactively(amount: amount)
}
}
Button(String(localized: "source_monthly_contribution_apply_forward"), role: .cancel) {}
} message: {
if let amount = pendingContributionAmount {
Text(String(format: String(localized: "source_monthly_contribution_apply_message"), amount.currencyString))
}
}
}
private func applyContributionRetroactively(amount: Decimal) {
let request = Snapshot.fetchRequest()
request.predicate = NSPredicate(format: "source == %@ AND (contribution == nil OR contribution == 0)", viewModel.source)
guard let snapshots = try? viewContext.fetch(request) else { return }
for snapshot in snapshots {
snapshot.contribution = NSDecimalNumber(decimal: amount)
}
try? viewContext.save()
}
// MARK: - Chart Section
private var chartSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Value History")
.font(.headline)
Chart {
ForEach(viewModel.chartData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
}
if viewModel.canViewPredictions, !viewModel.predictions.isEmpty {
ForEach(viewModel.predictions, id: \.id) { prediction in
AreaMark(
x: .value("Prediction Date", prediction.date),
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.18))
LineMark(
x: .value("Prediction Date", prediction.date),
y: .value("Predicted Value", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 4]))
}
if let firstPrediction = viewModel.predictions.first {
PointMark(
x: .value("Forecast Start", firstPrediction.date),
y: .value("Forecast Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue)
)
.symbolSize(30)
.foregroundStyle(Color.appSecondary)
.annotation(position: .topTrailing) {
Text("Forecast")
.font(.caption.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(Color.appSecondary.opacity(0.15))
.cornerRadius(8)
}
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.frame(height: 200)
if !viewModel.canViewPredictions && viewModel.hasEnoughDataForPredictions {
Button {
viewModel.showingPaywall = true
} label: {
HStack(spacing: 6) {
Image(systemName: "lock.fill")
Text("Unlock predictions on the chart")
}
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(10)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Metrics Section
private var metricsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Performance Metrics")
.font(.headline)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR)
MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility)
MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown)
MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio)
MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate)
MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var simpleMetricsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Performance Summary")
.font(.headline)
HStack(spacing: 12) {
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
.frame(maxWidth: .infinity)
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
.frame(maxWidth: .infinity)
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Snapshots Section
private var snapshotsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Snapshots")
.font(.headline)
Spacer()
Text("\(viewModel.snapshotCount)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if viewModel.isHistoryLimited {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.appWarning)
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
.font(.caption)
Spacer()
Button("Upgrade") {
viewModel.showingPaywall = true
}
.font(.caption.weight(.semibold))
}
.padding(8)
.background(Color.appWarning.opacity(0.1))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
// Use LazyVStack for better performance with many snapshots
LazyVStack(spacing: 0) {
ForEach(viewModel.visibleSnapshots, id: \.objectID) { snapshot in
VStack(spacing: 0) {
SnapshotRowView(snapshot: snapshot, onEdit: {
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
})
.contentShape(Rectangle())
.onTapGesture {
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
}
.contextMenu {
Button {
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
} label: {
Label("Edit", systemImage: "pencil")
}
}
.swipeActions(edge: .trailing) {
Button {
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) {
viewModel.deleteSnapshot(snapshot)
} label: {
Label("Delete", systemImage: "trash")
}
}
if snapshot.objectID != viewModel.visibleSnapshots.last?.objectID {
Divider()
}
}
}
}
// Show "more snapshots" only for free users who have limited history
if viewModel.isHistoryLimited && viewModel.hiddenSnapshotCount > 0 {
Text("+ \(viewModel.hiddenSnapshotCount) older snapshots hidden")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Metric Card
struct MetricCard: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Snapshot Row View
struct SnapshotRowView: View {
let snapshot: Snapshot
let onEdit: (() -> Void)?
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.safeDate.friendlyDescription)
.font(.subheadline)
if let notes = snapshot.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(snapshot.formattedValue)
.font(.subheadline.weight(.medium))
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
Text("+ \(snapshot.decimalContribution.currencyString)")
.font(.caption)
.foregroundColor(.appPrimary)
}
}
if let onEdit = onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.caption)
.foregroundColor(.secondary)
.padding(6)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
struct MetricChip: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.caption.weight(.semibold))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
}
}
private struct SnapshotSelection: Identifiable {
let id: NSManagedObjectID
}
private struct MissingSnapshotView: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundStyle(.orange)
Text("Snapshot not available")
.font(.headline)
Text("This snapshot was removed or is no longer accessible.")
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
}
}
#Preview {
NavigationStack {
Text("Source Detail Preview")
}
}