Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartsContainerView.swift
T
alexandrev-tibco 86c95a9e73 Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry
De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 20:36:15 +02:00

1593 lines
66 KiB
Swift

import SwiftUI
import Charts
import TipKit
struct ChartsContainerView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: ChartsViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@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(horizontalSizeClass == .regular ? "Charts" : viewModel.selectedChartType.rawValue)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .automatic : .inline)
// iPhone: the title doubles as the chart switcher (Files/Freeform
// pattern) replaces the 14-chip horizontal carousel.
.toolbarTitleMenu { chartTypePicker }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
if horizontalSizeClass != .regular {
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
}
}
.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: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
HStack(spacing: 0) {
// Left sidebar: native List selection (system hit-testing, keyboard
// navigation, pointer effects) replaces the custom tile grid whose
// Buttons were unreliable inside NavigationSplitView.
List(selection: sidebarSelection) {
ForEach(Self.chartGroups, id: \.titleKey) { group in
let types = group.types.filter { showForecast || $0 != .prediction }
if !types.isEmpty {
Section(String(localized: String.LocalizationValue(group.titleKey))) {
ForEach(types) { chartType in
sidebarRow(chartType)
.tag(chartType)
}
}
}
}
}
.listStyle(.sidebar)
.frame(width: 248)
Divider()
// Right panel: KPI header + period control + chart.
// Clipped so the decorative background can't paint over the sidebar.
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 16) {
kpiHeader
chartToolbar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
}
.padding()
}
}
.clipped()
}
.ignoresSafeArea(edges: .bottom)
// Attached here (not on the layout-switching Group): sheets presented from
// a view that gets replaced when the size class changes were unreliable in
// NavigationSplitView premium tiles looked like they "did nothing".
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - Chart groups (sidebar sections)
private static let chartGroups: [(titleKey: String, types: [ChartsViewModel.ChartType])] = [
("chart_group_overview", [.evolution, .allocation, .performance, .contributions]),
("chart_group_analyze", [.comparison, .periodComparison, .yearOverYear, .rollingReturn]),
("chart_group_risk", [.drawdown, .volatility, .riskReturn, .cashflow]),
("chart_group_forecast", [.prediction, .simulator])
]
/// Routes List selection through selectChart so premium gating (paywall)
/// stays in one place; a rejected selection simply reverts on re-render.
private var sidebarSelection: Binding<ChartsViewModel.ChartType?> {
Binding(
get: { viewModel.selectedChartType },
set: { newValue in
guard let newValue else { return }
viewModel.selectChart(newValue)
}
)
}
private func sidebarRow(_ chartType: ChartsViewModel.ChartType) -> some View {
HStack {
Label(chartType.rawValue, systemImage: chartType.icon)
Spacer()
if chartType.isPremium && !viewModel.isPremium {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
/// Grouped chart switcher for the title menu (iPhone) native Picker rows
/// get the system checkmark on the active chart.
private var chartTypePicker: some View {
Picker("Chart", selection: sidebarSelection) {
ForEach(Self.chartGroups, id: \.titleKey) { group in
let types = group.types.filter { showForecast || $0 != .prediction }
if !types.isEmpty {
Section(String(localized: String.LocalizationValue(group.titleKey))) {
ForEach(types) { chartType in
Label(
chartType.rawValue,
systemImage: (chartType.isPremium && !viewModel.isPremium) ? "lock.fill" : chartType.icon
)
.tag(ChartsViewModel.ChartType?.some(chartType))
}
}
}
}
}
}
// MARK: - Filters (toolbar menu)
private var hasActiveFilters: Bool {
let breakdownApplies = viewModel.selectedChartType == .allocation || viewModel.selectedChartType == .performance
return viewModel.selectedCategory != nil
|| !viewModel.selectedSourceIds.isEmpty
|| (breakdownApplies && viewModel.selectedBreakdown != .category)
}
@ViewBuilder
private var filterMenu: some View {
let chartType = viewModel.selectedChartType
let categories = viewModel.availableCategories(for: chartType)
let sources = viewModel.availableSources(for: chartType)
let hasBreakdown = chartType == .allocation || chartType == .performance
if hasBreakdown || !categories.isEmpty || !sources.isEmpty {
Menu {
if hasBreakdown {
Picker("Group", selection: $viewModel.selectedBreakdown) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.inline)
}
if !categories.isEmpty {
Picker("Category", selection: categorySelection) {
Text("All Categories").tag(Category?.none)
ForEach(categories) { category in
Text(category.name).tag(Optional(category))
}
}
.pickerStyle(.menu)
}
if !sources.isEmpty {
Menu("Sources") {
ForEach(sources) { source in
Toggle(source.name, isOn: sourceToggle(source))
}
if !viewModel.selectedSourceIds.isEmpty {
Divider()
Button("Show All Sources") {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
}
}
}
}
if hasActiveFilters {
Divider()
Button("Reset Filters", role: .destructive) {
viewModel.selectedCategory = nil
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
viewModel.selectedBreakdown = .category
}
}
} label: {
Image(systemName: hasActiveFilters
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel("Filters")
}
}
private var categorySelection: Binding<Category?> {
Binding(
get: { viewModel.selectedCategory },
set: { newValue in
viewModel.selectedCategory = newValue
viewModel.selectedSource = nil
}
)
}
private func sourceToggle(_ source: InvestmentSource) -> Binding<Bool> {
Binding(
get: { viewModel.selectedSourceIds.contains(source.id) },
set: { isOn in
viewModel.selectedSource = nil
if isOn {
viewModel.selectedSourceIds.insert(source.id)
} else {
viewModel.selectedSourceIds.remove(source.id)
}
}
)
}
// MARK: - KPI Header (iPad)
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
/// that make sense for it (the same ones its in-card stats row shows on
/// iPhone) instead of the always-identical portfolio aggregates.
private var selectedChartLocked: Bool {
viewModel.selectedChartType.isPremium && !viewModel.isPremium
}
@ViewBuilder
private var kpiHeader: some View {
let stats = selectedChartLocked ? [] : chartSpecificStats
if !stats.isEmpty {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
kpiCard(label: stats[i].label, value: stats[i].value, color: stats[i].color)
}
}
}
}
private var chartSpecificStats: [ChartStat] {
switch viewModel.selectedChartType {
case .evolution:
let data = viewModel.evolutionData
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 changePct = first > 0 ? ((latest - first) / first) * 100 : 0
let m = viewModel.portfolioMetrics
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: "CAGR", value: m.formattedCAGR, color: m.cagr >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Max", value: Decimal(values.max() ?? 0).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Min", value: Decimal(values.min() ?? 0).compactCurrencyString, color: .negativeRed),
]
case .allocation:
let data = viewModel.allocationData
guard !data.isEmpty else { return [] }
let total = data.reduce(Decimal.zero) { $0 + $1.value }
let top = data[0]
let topPct = total > 0 ? NSDecimalNumber(decimal: top.value / total * 100).doubleValue : 0
return [
ChartStat(label: "Total", value: total.compactCurrencyString, color: .primary),
ChartStat(label: "Largest", value: top.category, color: Color(hex: top.color) ?? .appPrimary),
ChartStat(label: "Top share", value: String(format: "%.1f%%", topPct), color: .appPrimary),
ChartStat(label: "Positions", value: "\(data.count)", color: .secondary),
]
case .performance:
let data = viewModel.performanceData
guard !data.isEmpty else { return [] }
let avg = data.map { $0.cagr }.reduce(0, +) / Double(data.count)
let best = data.max(by: { $0.cagr < $1.cagr })
let worst = data.min(by: { $0.cagr < $1.cagr })
return [
ChartStat(label: "Best", value: best.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .positiveGreen),
ChartStat(label: "Worst", value: worst.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%+.1f%%", avg), color: .secondary),
]
case .contributions:
let data = viewModel.contributionsData
guard !data.isEmpty else { return [] }
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
let total = amounts.reduce(0, +)
return [
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
ChartStat(label: "Monthly avg", value: Decimal(total / Double(amounts.count)).compactCurrencyString, color: .secondary),
ChartStat(label: "Highest", value: Decimal(amounts.max() ?? 0).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Last", value: Decimal(amounts.last ?? 0).compactCurrencyString, color: .appPrimary),
]
case .rollingReturn:
let values = viewModel.rollingReturnData.map { $0.value }
guard !values.isEmpty else { return [] }
let latest = values.last ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best", value: String(format: "%.1f%%", values.max() ?? 0), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", values.min() ?? 0), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .riskReturn:
let data = viewModel.riskReturnData
guard !data.isEmpty else { return [] }
let bestReturn = data.max(by: { $0.cagr < $1.cagr })
let lowestRisk = data.min(by: { $0.volatility < $1.volatility })
return [
ChartStat(label: "Best return", value: bestReturn.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .positiveGreen),
ChartStat(label: "Lowest risk", value: lowestRisk.map { "\($0.category) \(String(format: "%.1f%%", $0.volatility))" } ?? "", color: .appPrimary),
ChartStat(label: "Categories", value: "\(data.count)", color: .secondary),
]
case .cashflow:
let data = viewModel.cashflowData
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),
]
case .drawdown:
let values = viewModel.drawdownData.map { $0.drawdown }
guard !values.isEmpty else { return [] }
let current = values.last ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Current", value: String(format: "%.1f%%", current), color: current >= -1 ? .positiveGreen : .negativeRed),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .volatility:
let values = viewModel.volatilityData.map { $0.volatility }
guard !values.isEmpty else { return [] }
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Current", value: String(format: "%.1f%%", values.last ?? 0), color: .appPrimary),
ChartStat(label: "Max", value: String(format: "%.1f%%", values.max() ?? 0), color: .negativeRed),
ChartStat(label: "Min", value: String(format: "%.1f%%", values.min() ?? 0), color: .positiveGreen),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .prediction:
guard let lastPred = viewModel.predictionData.last else { return [] }
let current = viewModel.evolutionData.last?.value ?? 0
return [
ChartStat(label: "Current", value: current.compactCurrencyString, color: .appPrimary),
ChartStat(label: "Base (\(viewModel.predictionData.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
]
case .comparison:
let series = viewModel.comparisonData
guard !series.isEmpty else { return [] }
// Rank sources by their latest value in the current display mode.
let ranked = series.compactMap { s -> (name: String, val: Double)? in
guard let last = s.points.last?.value else { return nil }
return (s.name, last)
}.sorted { $0.val > $1.val }
let unit = viewModel.comparisonDisplayMode == .absolute ? "" : "%"
func fmt(_ v: Double) -> String { unit.isEmpty ? Decimal(v).compactCurrencyString : String(format: "%+.1f%%", v) }
var stats: [ChartStat] = [ChartStat(label: "Series", value: "\(series.count)", color: .secondary)]
if let best = ranked.first {
stats.append(ChartStat(label: "Leader", value: "\(best.name) \(fmt(best.val))", color: .positiveGreen))
}
if ranked.count > 1, let worst = ranked.last {
stats.append(ChartStat(label: "Laggard", value: "\(worst.name) \(fmt(worst.val))", color: .negativeRed))
}
return stats
case .periodComparison:
let series = viewModel.periodComparisonData
guard !series.isEmpty else { return [] }
return series.map { s in
let end = s.points.last?.returnPct ?? 0
return ChartStat(label: s.label, value: String(format: "%+.1f%%", end),
color: end >= 0 ? .positiveGreen : .negativeRed)
}
case .yearOverYear:
let all = viewModel.yearOverYearData.sorted { $0.year < $1.year }
guard let latest = all.last else { return [] }
func endReturn(_ ys: ChartsViewModel.YearSeries) -> Double {
ys.forecastEndValue ?? (ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0)
}
let latestEnd = endReturn(latest)
var stats: [ChartStat] = [
ChartStat(label: "\(latest.year)", value: String(format: "%+.1f%%", latestEnd),
color: latestEnd >= 0 ? .positiveGreen : .negativeRed)
]
// Current year vs previous year (end-of-year return difference).
if all.count >= 2 {
let prev = all[all.count - 2]
let diff = latestEnd - endReturn(prev)
stats.append(ChartStat(
label: String(format: String(localized: "yoy_vs_prev"), "\(prev.year)"),
value: String(format: "%+.1f pp", diff),
color: diff >= 0 ? .positiveGreen : .negativeRed))
}
// Best and worst full year on record.
if all.count >= 2 {
let ranked = all.map { ($0.year, endReturn($0)) }.sorted { $0.1 > $1.1 }
if let best = ranked.first {
stats.append(ChartStat(label: String(localized: "yoy_best_year"),
value: "\(best.0) \(String(format: "%+.1f%%", best.1))",
color: .positiveGreen))
}
if let worst = ranked.last, worst.0 != ranked.first?.0 {
stats.append(ChartStat(label: String(localized: "yoy_worst_year"),
value: "\(worst.0) \(String(format: "%+.1f%%", worst.1))",
color: .negativeRed))
}
}
return stats
case .simulator:
// The simulator carries its own live sliders and comparison inline.
return []
}
}
private func kpiCard(label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(value)
.font(.system(.title3, design: .rounded).weight(.semibold))
.foregroundColor(color)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
}
// MARK: - Chart toolbar (iPad): title + native segmented period picker
@ViewBuilder
private var chartToolbar: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedChartType.rawValue)
.font(.headline)
Text(viewModel.selectedChartType.description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
if viewModel.selectedChartType == .performance {
performancePeriodPicker
.frame(maxWidth: 360)
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 360)
}
shareButton
}
.padding(.horizontal, 4)
}
/// Months of history actually available (distinct aggregated months). The
/// Performance period options never exceed this, so the control can't ask
/// for a window with no data.
private var availableHistoryMonths: Int {
max(1, viewModel.evolutionData.count)
}
/// Discrete period options (in months) capped to available data mirrors
/// the segmented period control used by every other chart for consistency.
private var performancePeriodOptions: [(label: String, months: Int)] {
let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")]
var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) }
opts.append(("All", availableHistoryMonths)) // full history
return opts.map { (label: $0.0, months: $0.1) }
}
private var performancePeriodPicker: some View {
Picker("Period", selection: Binding(
get: {
// Snap to the closest available option.
let m = viewModel.performancePeriodMonths
return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months
?? availableHistoryMonths
},
set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) }
)) {
ForEach(performancePeriodOptions, id: \.months) { opt in
Text(opt.label).tag(opt.months)
}
}
.pickerStyle(.segmented)
}
private var shareButton: some View {
Button {
ChartShareTip().invalidate(reason: .actionPerformed)
shareCurrentChart()
} label: {
Image(systemName: "square.and.arrow.up")
}
.popoverTip(ChartShareTip())
.disabled(!viewModel.hasData || viewModel.isLoading || selectedChartLocked)
.accessibilityLabel(String(localized: "chart_share_button"))
}
private func shareCurrentChart() {
ChartShareService.shared.share(
title: viewModel.selectedChartType.rawValue,
subtitle: viewModel.selectedChartType.description,
stats: chartSpecificStats
) {
chartContent
.environmentObject(accountStore)
.frame(width: 672)
}
}
/// Concrete loss-framing upsell: the user has real data older than the free
/// 12-month window; tell them exactly how much is locked instead of hiding it.
private var lockedHistoryTeaser: some View {
Button {
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
viewModel.showingPaywall = true
} label: {
HStack(spacing: 8) {
Image(systemName: "lock.fill")
Text(String(format: String(localized: "chart_history_locked"), viewModel.hiddenHistoryMonths))
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
}
.font(.footnote.weight(.medium))
.foregroundColor(.appPrimary)
.padding(12)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
.buttonStyle(.plain)
}
// MARK: - iPhone Layout
private var iPhoneChartsLayout: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
iPhonePeriodControl
chartContent
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
}
.padding()
}
}
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
/// Stocks-style segmented period control above the chart (the chart switcher
/// lives in the title menu, filters in the toolbar).
@ViewBuilder
private var iPhonePeriodControl: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
if viewModel.selectedChartType == .performance {
performancePeriodPicker
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
}
}
// 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)
}
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.selectedChartType.isPremium && !viewModel.isPremium {
premiumLockedView
} else 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)
}
}
}
/// Shown in place of a premium chart when the user isn't premium. Selecting
/// the chart always works; unlocking is an explicit button tap (a reliable
/// context for presenting the paywall sheet).
private var premiumLockedView: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 88, height: 88)
Image(systemName: viewModel.selectedChartType.icon)
.font(.system(size: 34))
.foregroundColor(.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 26))
.foregroundColor(.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 32, y: 30)
}
Text(String(format: String(localized: "chart_locked_title"), viewModel.selectedChartType.rawValue))
.font(.headline)
Text(viewModel.selectedChartType.description)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
viewModel.showingPaywall = true
} label: {
Text(String(localized: "chart_locked_cta"))
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(24)
}
}
.padding(32)
.frame(maxWidth: .infinity, minHeight: 320)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
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: - Evolution Chart View
struct EvolutionChartView: View {
let data: [(date: Date, value: Decimal)]
@State private var zoom = ChartZoomModel()
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@Environment(\.chartImageExport) private var chartImageExport
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
@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)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Performance: GPU rendering for smoother scrolling on older devices
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@ChartContentBuilder
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
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)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
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]))
}
}
// Scrub/tap selection bubble (total mode)
if chartMode == .total, let sel = selectedDataPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(80)
.annotation(position: .top, spacing: 4) {
ChartValueBubble(text: sel.value.compactCurrencyString, prominent: true)
}
}
}
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)]
@State private var zoom = ChartZoomModel()
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)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
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)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, value: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
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(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
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)
.annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.value))
}
}
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Return", sel.value)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(70)
.annotation(position: sel.value >= 0 ? .top : .bottom, spacing: 4) {
ChartValueBubble(text: ChartLabels.percent(sel.value), prominent: true)
}
}
}
.chartXSelection(value: $selectedDate)
.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)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
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)]
@State private var zoom = ChartZoomModel()
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)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
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)]
@Environment(\.chartImageExport) private var chartImageExport
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)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
}
#Preview {
ChartsContainerView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}