Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartsContainerView.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

1395 lines
53 KiB
Swift

import SwiftUI
import Charts
struct ChartsContainerView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: ChartsViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
}
var body: some View {
NavigationStack {
Group {
if horizontalSizeClass == .regular {
iPadChartsLayout
} else {
iPhoneChartsLayout
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
}
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
goalsViewModel.selectedAccount = newAccount
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: accountStore.showAllAccounts) { _, showAll in
viewModel.showAllAccounts = showAll
goalsViewModel.showAllAccounts = showAll
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: goalsViewModel.goals) { _, goals in
viewModel.updatePredictionTargetDate(goals)
}
}
}
// MARK: - Focus Mode Button
private var focusModeButton: some View {
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
Text(calmModeEnabled ? "Focus" : "Full")
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.cornerRadius(14)
}
.buttonStyle(.plain)
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
HStack(spacing: 0) {
// Left sidebar: chart type list + filters
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Chart Type")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
.filter { showForecast || $0 != .prediction }
ForEach(types) { chartType in
iPadChartTypeRow(chartType)
}
Divider().padding(.vertical, 8)
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
.font(.system(size: 14))
.foregroundColor(calmModeEnabled ? .white : .primary)
}
Text("Focus Mode")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.font(.system(size: 16))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
}
.buttonStyle(.plain)
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
}
.frame(width: 220)
.background(Color(.systemBackground))
Divider()
// Right panel: full-width chart
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
}
.padding()
}
}
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone Layout
private var iPhoneChartsLayout: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
chartTypeSelector
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
filtersSection
chartContent
}
.padding()
}
}
}
// MARK: - iPad Chart Type Row
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
return Button {
viewModel.selectChart(chartType)
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: chartType.icon)
.font(.system(size: 14))
.foregroundColor(isSelected ? .white : .primary)
}
Text(chartType.rawValue)
.font(.subheadline)
.foregroundColor(isPremiumLocked ? .secondary : .primary)
Spacer()
if isPremiumLocked {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
}
.buttonStyle(.plain)
}
// MARK: - Shared
private func syncState() {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in
ChartTypeButton(
chartType: chartType,
isSelected: viewModel.selectedChartType == chartType,
isPremium: chartType.isPremium,
userIsPremium: viewModel.isPremium
) {
viewModel.selectChart(chartType)
}
}
}
.padding(.horizontal, 4)
}
}
// MARK: - Unified Filters Section
private var hasAnyFilter: Bool {
let chartType = viewModel.selectedChartType
// These chart types manage their own controls internally, no external filter bar needed
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
return !viewModel.availableTimeRanges(for: chartType).isEmpty
}
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
let hasBreakdown = chartType == .allocation || chartType == .performance
return hasTimeRange || hasCategories || hasSources || hasBreakdown
}
@ViewBuilder
private var filtersSection: some View {
if hasAnyFilter {
VStack(alignment: .leading, spacing: 10) {
let chartType = viewModel.selectedChartType
// Time Range (not for performance uses slider)
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
filterRow(icon: "calendar", label: "Period") {
timeRangeSelector
}
}
// Performance: custom period slider
if chartType == .performance {
filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") {
Slider(
value: Binding(
get: { Double(viewModel.performancePeriodMonths) },
set: { viewModel.performancePeriodMonths = Int($0) }
),
in: 1...60,
step: 1
)
.tint(.appPrimary)
}
}
// Breakdown (category vs source)
if chartType == .allocation || chartType == .performance {
filterRow(icon: "square.grid.2x2", label: "Group") {
breakdownSelector
}
}
// Category
let availableCategories = viewModel.availableCategories(for: chartType)
if !availableCategories.isEmpty {
filterRow(icon: "tag", label: "Category") {
categoryFilter
}
}
// Source
let availableSources = viewModel.availableSources(for: chartType)
if !availableSources.isEmpty {
filterRow(icon: "building.2", label: "Source") {
sourceFilter
}
}
}
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
}
}
private func filterRow<Content: View>(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption2)
.foregroundColor(.secondary)
Text(label)
.font(.caption.weight(.medium))
.foregroundColor(.secondary)
}
content()
}
}
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
Group {
if horizontalSizeClass == .regular {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: true)
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: false)
}
}
}
}
}
}
private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View {
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: fullWidth ? .infinity : nil)
.padding(.horizontal, fullWidth ? 8 : 16)
.padding(.vertical, 8)
.background(viewModel.selectedTimeRange == range ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedTimeRange == range ? .white : .primary)
.cornerRadius(20)
}
}
// MARK: - Category Filter
@ViewBuilder
private var categoryFilter: some View {
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
if !availableCategories.isEmpty {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableCategories.count > 1 {
categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
}
}
ForEach(availableCategories) { category in
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableCategories.count > 1 {
categoryPill(label: "All", color: .appPrimary,
isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
}
}
ForEach(availableCategories) { category in
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
}
}
}
}
}
}
}
private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Source Filter
@ViewBuilder
private var sourceFilter: some View {
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
if !availableSources.isEmpty {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableSources.count > 1 {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
}
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableSources.count > 1 {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
}
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
}
}
}
}
}
}
private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Breakdown Selector
private var breakdownSelector: some View {
Group {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
} else {
HStack(spacing: 8) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
}
}
}
private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View {
Button {
viewModel.selectedBreakdown = mode
} label: {
Text(mode.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary)
.cornerRadius(18)
}
}
private func periodLabel(_ months: Int) -> String {
if months < 12 {
return "\(months)M"
} else if months % 12 == 0 {
return "\(months / 12)Y"
} else {
return "\(months / 12)Y \(months % 12)M"
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.isLoading {
ProgressView()
.frame(height: 300)
} else if !viewModel.hasData {
emptyStateView
} else {
switch viewModel.selectedChartType {
case .evolution:
EvolutionChartView(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals
)
case .allocation:
VStack(spacing: 20) {
AllocationPieChart(
data: viewModel.allocationData,
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
)
if !viewModel.allocationEvolutionData.isEmpty {
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
}
}
case .performance:
PerformanceBarChart(
data: viewModel.performanceData,
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
)
case .contributions:
ContributionsChartView(data: viewModel.contributionsData)
case .rollingReturn:
RollingReturnChartView(data: viewModel.rollingReturnData)
case .riskReturn:
RiskReturnChartView(data: viewModel.riskReturnData)
case .cashflow:
CashflowStackedChartView(data: viewModel.cashflowData)
case .drawdown:
DrawdownChart(data: viewModel.drawdownData)
case .volatility:
VolatilityChartView(data: viewModel.volatilityData)
case .prediction:
PredictionChartView(
predictions: viewModel.predictionData,
historicalData: viewModel.evolutionData
)
case .yearOverYear:
YearOverYearChartView(viewModel: viewModel)
case .comparison:
ComparisonChartView(viewModel: viewModel)
case .simulator:
AllocationSimulatorView(viewModel: viewModel)
case .periodComparison:
PeriodComparisonChartView(viewModel: viewModel)
}
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No Data Available")
.font(.headline)
Text("Add some investment sources and snapshots to see charts.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 300)
}
private var accountFilterMenu: some View {
Menu {
Button {
accountStore.selectAllAccounts()
} label: {
HStack {
Text("All Accounts")
if accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(availableAccounts, id: \.objectID) { account in
Button {
accountStore.selectAccount(account)
} label: {
HStack {
Text(account.name)
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: "person.2.circle")
}
}
private var availableAccounts: [Account] {
accountStore.accounts.filter { $0.safeId != nil }
}
}
// MARK: - Chart Type Button
struct ChartTypeButton: View {
let chartType: ChartsViewModel.ChartType
let isSelected: Bool
let isPremium: Bool
let userIsPremium: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
ZStack(alignment: .topTrailing) {
Image(systemName: chartType.icon)
.font(.title2)
if isPremium && !userIsPremium {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.offset(x: 8, y: -4)
}
}
Text(chartType.rawValue)
.font(.caption)
}
.frame(width: 80, height: 70)
.background(
isSelected
? LinearGradient.appPrimaryGradient
: LinearGradient(
colors: [
Color(.systemBackground).opacity(0.85),
Color(.systemBackground).opacity(0.85)
],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
}
// MARK: - Evolution Chart View
struct EvolutionChartView: View {
let data: [(date: Date, value: Decimal)]
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = false
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
case byCategory = "By Category"
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
private var xAxisMonthStride: Int {
switch data.count {
case ...8:
return 1
case ...16:
return 2
case ...30:
return 3
case ...48:
return 4
default:
return 6
}
}
private var stackedCategoryData: [CategoryEvolutionPoint] {
guard !categoryData.isEmpty else { return [] }
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
result[point.categoryName, default: 0] += point.value
}
let categoryOrder = totalsByCategory
.sorted { $0.value > $1.value }
.map { $0.key }
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
let sortedDates = groupedByDate.keys.sorted()
var stacked: [CategoryEvolutionPoint] = []
for date in sortedDates {
guard let points = groupedByDate[date] else { continue }
let sortedPoints = points.sorted {
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
}
var running: Decimal = 0
for point in sortedPoints {
running += point.value
stacked.append(CategoryEvolutionPoint(
date: point.date,
categoryName: point.categoryName,
colorHex: point.colorHex,
value: running
))
}
}
return stacked
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
modePicker
chartSection
if !data.isEmpty && chartMode == .total {
ChartStatsRow(stats: evolutionStats)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var evolutionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
let latest = values.last ?? 0
let first = values.first ?? 0
let change = latest - first
let changePct = first > 0 ? (change / first) * 100 : 0
let maxVal = values.max() ?? 0
let minVal = values.min() ?? 0
return [
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed),
]
}
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
.font(.headline)
Spacer()
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing, spacing: 2) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
}
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
}
}
private var modePicker: some View {
Picker("Evolution Mode", selection: $chartMode) {
ForEach(ChartMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var chartSection: some View {
if data.count >= 2 {
chartView
} else {
Text("Not enough data")
.foregroundColor(.secondary)
.frame(height: 300)
}
}
private var chartView: some View {
Chart {
chartMarks
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.chartOverlay { proxy in
if chartMode == .total {
GeometryReader { geometry in
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let plotFrameAnchor = proxy.plotFrame else { return }
let plotFrame = geometry[plotFrameAnchor]
let x = value.location.x - plotFrame.origin.x
guard let date: Date = proxy.value(atX: x) else { return }
if let closest = data.min(by: {
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
}) {
selectedDataPoint = closest
}
}
.onEnded { _ in
selectedDataPoint = nil
}
)
}
}
}
.frame(height: 300)
// Performance: Use GPU rendering for smoother scrolling on older devices
.drawingGroup()
}
@ChartContentBuilder
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(30)
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)
}
case .byCategory:
ForEach(categoryData) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
.opacity(0.5)
}
ForEach(stackedCategoryData) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.symbolSize(18)
}
}
if showGoalLines {
ForEach(goals) { goal in
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
.foregroundStyle(Color.appSecondary.opacity(0.4))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
}
}
}
private var chartCategoryNames: [String] {
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
return names
}
private var chartCategoryColors: [Color] {
chartCategoryNames.map { name in
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
}
// MARK: - Contributions Chart
struct ContributionsChartView: View {
let data: [(date: Date, amount: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Contributions")
.font(.headline)
if data.isEmpty {
Text("No contributions yet.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
BarMark(
x: .value("Month", item.date),
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.cornerRadius(6)
}
}
.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: 260)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var contributionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
let total = amounts.reduce(0, +)
let avg = total / Double(amounts.count)
let highest = amounts.max() ?? 0
let last = amounts.last ?? 0
return [
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary),
ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary),
]
}
}
// MARK: - Rolling 12-Month Return
struct RollingReturnChartView: View {
let data: [(date: Date, value: Double)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rolling 12-Month Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data for rolling returns.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(24)
}
}
.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(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
valueHeader: "Return",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var rollingStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.value }
let latest = values.last ?? 0
let best = values.max() ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
let change = (values.last ?? 0) - (values.first ?? 0)
return [
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed),
]
}
private var rollingTableRows: [ChartDataTableRow] {
guard !data.isEmpty else { return [] }
let firstVal = data.first?.value ?? 0
return data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].value : point.value
let deltaPrev = point.value - prevVal
let deltaFirst = point.value - firstVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.value),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
// MARK: - Risk vs Return
struct RiskReturnChartView: View {
let data: [(category: String, cagr: Double, volatility: Double, color: String)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Risk vs Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare categories.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.category) { item in
PointMark(
x: .value("Volatility", item.volatility),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.symbolSize(60)
.annotation(position: .top) {
Text(item.category)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
.chartXAxis {
AxisMarks(position: .bottom) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Net Performance vs Contributions
struct CashflowStackedChartView: View {
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Net Performance vs Contributions")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare cashflow.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue
let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue
let stackedEnd = contributionValue + netValue
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", 0),
yEnd: .value("Contributions", contributionValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.8))
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", contributionValue),
yEnd: .value("Net", stackedEnd)
)
.foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.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: 260)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var cashflowStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions }
let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance }
let net = totalContrib + totalPerf
return [
ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed),
]
}
}
// MARK: - Allocation Evolution Chart
struct AllocationEvolutionDataPoint: Identifiable {
let id: String
let date: Date
let category: String
let percentage: Double
let color: String
}
struct AllocationEvolutionChart: View {
let data: [(date: Date, category: String, percentage: Double, color: String)]
private var identifiableData: [AllocationEvolutionDataPoint] {
data.enumerated().map { index, item in
AllocationEvolutionDataPoint(
id: "\(index)-\(item.category)",
date: item.date,
category: item.category,
percentage: item.percentage,
color: item.color
)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Allocation Over Time")
.font(.headline)
if data.isEmpty {
emptyView
} else {
chartView
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var emptyView: some View {
Text("Not enough data to show allocation evolution.")
.foregroundColor(.secondary)
.frame(height: 260)
}
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
private var stableCategoryNames: [String] {
var seen = Set<String>()
var ordered: [String] = []
for item in data {
if seen.insert(item.category).inserted {
ordered.append(item.category)
}
}
return ordered
}
private var stableCategoryColors: [Color] {
stableCategoryNames.map { name in
if let hex = data.first(where: { $0.category == name })?.color {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
private var chartView: some View {
Chart(identifiableData) { item in
BarMark(
x: .value("Date", item.date, unit: .month),
y: .value("Percentage", item.percentage)
)
.foregroundStyle(by: .value("Category", item.category))
}
.chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let pct = value.as(Double.self) {
Text(String(format: "%.0f%%", pct))
.font(.caption)
}
}
}
}
.frame(height: 260)
.drawingGroup()
}
}
#Preview {
ChartsContainerView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}