Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartsContainerView.swift
T
alexandrev-tibco 680255f69d iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las
  size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o
  cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab.
- Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho
  regular, stack colapsable en compacto) con selección nativa de List.
- Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos
  en regular, sheet en compacto).
- Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:),
  sin ignoresSafeArea en contenido interactivo.
- Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00

2005 lines
84 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
import CoreData
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: .topBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .topBarTrailing) { filterMenu }
if horizontalSizeClass != .regular {
ToolbarItem(placement: .topBarTrailing) { 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 showRangeBrush && supportsGlobalRange {
globalRangeBrush
.transition(.opacity.combined(with: .move(edge: .top)))
}
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
}
.padding()
// Regular width has a wide canvas next to the sidebar: give
// every plot ~35% more height so the extra area shows data,
// not just a stretched iPhone chart.
.environment(\.chartHeightScale, 1.35)
}
}
.clipped()
}
// 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)
.foregroundStyle(.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)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(value)
.font(.system(.title3, design: .rounded).weight(.semibold))
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(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)
.foregroundStyle(.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)
}
if supportsGlobalRange {
rangeToggleButton
}
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))
.foregroundStyle(Color.appPrimary)
.padding(12)
.background(Color.appPrimary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
}
.buttonStyle(.plain)
}
// MARK: - iPhone Layout
private var iPhoneChartsLayout: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 16) {
chartTypeChipBar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
iPhonePeriodControl
if hasActiveFilters {
activeFilterChip
}
// No swipe-between-charts: it collided with every horizontal
// interaction (scrub, pan, brush). The chip bar navigates.
chartContent
.id(viewModel.selectedChartType)
.transition(.opacity)
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
}
.padding()
}
}
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone chart type chip bar
//
// Always-visible horizontal switcher (Health-app pattern) replaces the
// hidden title menu as primary navigation and the broken page-dot row.
// Swipe still works and stays in sync via the auto-scroll.
private var chartTypeChipBar: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(orderedChartTypes) { chartType in
chartTypeChip(chartType)
.id(chartType)
}
}
.padding(.horizontal, 2)
.padding(.vertical, 2)
}
.onChange(of: viewModel.selectedChartType) { _, newValue in
withAnimation(.snappy) { proxy.scrollTo(newValue, anchor: .center) }
}
.onAppear {
proxy.scrollTo(viewModel.selectedChartType, anchor: .center)
}
}
}
private func chartTypeChip(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let locked = chartType.isPremium && !viewModel.isPremium
return Button {
guard !isSelected else { return }
ChartHaptics.tick()
withAnimation(.snappy) { viewModel.selectChart(chartType) }
} label: {
HStack(spacing: 5) {
Image(systemName: locked ? "lock.fill" : chartType.icon)
.font(.system(size: 11, weight: .semibold))
Text(chartType.rawValue)
.font(.footnote.weight(isSelected ? .semibold : .regular))
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.foregroundStyle(isSelected ? .white : (locked ? .secondary : .primary))
.background(
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
)
.overlay(
Capsule().stroke(isSelected ? Color.clear : Color.gray.opacity(0.25), lineWidth: 0.8)
)
}
.buttonStyle(.plain)
}
/// Visible affordance for an active drill-down/filter tap to clear.
private var activeFilterChip: some View {
let name = viewModel.selectedCategory?.name
?? viewModel.availableSources(for: viewModel.selectedChartType)
.first(where: { viewModel.selectedSourceIds.contains($0.id) })?.name
?? ""
return HStack {
Button {
withAnimation(.snappy) {
viewModel.selectedCategory = nil
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
viewModel.selectedBreakdown = .category
}
ChartHaptics.bump()
} label: {
HStack(spacing: 6) {
Text(String(format: String(localized: "chart_filtered_prefix"), name))
.font(.footnote.weight(.medium))
Image(systemName: "xmark.circle.fill")
.font(.footnote)
}
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
}
.buttonStyle(.plain)
Spacer()
}
}
/// Chart types in the same grouped order as the title switcher menu.
private var orderedChartTypes: [ChartsViewModel.ChartType] {
Self.chartGroups.flatMap { $0.types }.filter { showForecast || $0 != .prediction }
}
/// Stocks-style segmented period control above the chart (the chart switcher
/// lives in the title menu, filters in the toolbar), plus the global range
/// brush toggle. The brush lives HERE outside chartContent so its drags
/// can't collide with the chart-swipe gesture, and its window filters the
/// data of EVERY chart (it replaces the preset as data range).
@ViewBuilder
private var iPhonePeriodControl: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
VStack(spacing: 10) {
HStack(spacing: 8) {
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)
}
if supportsGlobalRange {
rangeToggleButton
}
}
if showRangeBrush && supportsGlobalRange {
globalRangeBrush
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
// MARK: - Global range brush (shared data window for all charts)
@State private var showRangeBrush = false
/// Charts whose data honors customRange (Performance/Simulator/Period vs
/// Period fetch their own windows).
private var supportsGlobalRange: Bool {
!viewModel.availableTimeRanges(for: viewModel.selectedChartType).isEmpty
&& viewModel.selectedChartType != .performance
&& viewModel.fullHistorySeries.count >= 2
}
private var rangeToggleButton: some View {
Button {
withAnimation(.snappy) { showRangeBrush.toggle() }
ChartHaptics.bump()
if showRangeBrush {
// Load the full history so any window can be selected.
if viewModel.selectedTimeRange != .all { viewModel.selectedTimeRange = .all }
} else {
viewModel.customRange = nil
}
} label: {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(showRangeBrush ? .white : Color.appPrimary)
.frame(width: 38, height: 30)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(showRangeBrush ? Color.appPrimary : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(showRangeBrush ? 0 : 0.3), lineWidth: 0.8)
)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "chart_range_button"))
}
@ViewBuilder
private var globalRangeBrush: some View {
let series = viewModel.fullHistorySeries
if series.count >= 2 {
ChartRangeBrush(
dates: series.map(\.date),
values: series.map { NSDecimalNumber(decimal: $0.value).doubleValue },
selection: rangeSelection
)
}
}
/// Maps the brush's month indices to the view model's date window.
/// Full selection = no filter (nil).
private var rangeSelection: Binding<ClosedRange<Int>> {
Binding(
get: {
let dates = viewModel.fullHistorySeries.map(\.date)
guard let last = dates.indices.last else { return 0...0 }
guard let range = viewModel.customRange else { return 0...last }
let lo = nearestIndex(in: dates, to: range.lowerBound)
let hi = nearestIndex(in: dates, to: range.upperBound)
return min(lo, hi)...max(lo, hi)
},
set: { sel in
let dates = viewModel.fullHistorySeries.map(\.date)
guard let last = dates.indices.last else { return }
if sel.lowerBound <= 0 && sel.upperBound >= last {
viewModel.customRange = nil
} else {
viewModel.setCustomRange(
fromMonth: dates[max(0, sel.lowerBound)],
toMonth: dates[min(last, sel.upperBound)]
)
}
}
)
}
private func nearestIndex(in dates: [Date], to date: Date) -> Int {
var best = 0
var bestDist = TimeInterval.greatestFiniteMagnitude
for (i, d) in dates.enumerated() {
let dist = abs(d.timeIntervalSince(date))
if dist < bestDist { best = i; bestDist = dist }
}
return best
}
// 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)
}
/// Drill-down: tapping a category/source in Allocation or Performance jumps
/// to Evolution filtered to it (premium). The active-filter chip is the way
/// back.
private func drillDown(to name: String) {
guard viewModel.isPremium else {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
return
}
ChartHaptics.bump()
withAnimation(.snappy) {
if viewModel.selectedBreakdown == .source {
if let source = viewModel.availableSources(for: viewModel.selectedChartType)
.first(where: { $0.name == name }) {
viewModel.selectedCategory = nil
viewModel.selectedSource = nil
viewModel.selectedSourceIds = [source.id]
}
} else if let category = viewModel.availableCategories(for: viewModel.selectedChartType)
.first(where: { $0.name == name }) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
viewModel.selectedCategory = category
}
viewModel.selectChart(.evolution)
}
}
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()
.chartFrame(height: 300)
} else if !viewModel.hasData {
emptyStateView
} else {
switch viewModel.selectedChartType {
case .evolution:
EvolutionChartView(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals,
contributions: viewModel.contributionsData,
isPremium: viewModel.isPremium,
onPremiumNeeded: {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
}
)
case .allocation:
VStack(spacing: 20) {
AllocationPieChart(
data: viewModel.allocationData,
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown),
onDrillDown: { name in drillDown(to: name) }
)
if !viewModel.allocationEvolutionData.isEmpty {
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
}
}
case .performance:
PerformanceBarChart(
data: viewModel.performanceData,
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category",
onDrillDown: { name in drillDown(to: name) }
)
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))
.foregroundStyle(Color.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 26))
.foregroundStyle(Color.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)
.foregroundStyle(.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)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 24))
}
}
.padding(32)
.frame(maxWidth: .infinity, minHeight: 320)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(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))
.foregroundStyle(.secondary)
Text("No Data Available")
.font(.headline)
Text("Add some investment sources and snapshots to see charts.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.chartFrame(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]
var contributions: [(date: Date, amount: Decimal)] = []
var isPremium: Bool = true
var onPremiumNeeded: (() -> Void)? = nil
@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
@State private var showContributions = 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
if chartMode == .total && !contributions.isEmpty {
HStack {
contributionsToggle
Spacer()
}
}
chartSection
if chartMode == .total, isPremium, let insight = rangeInsight {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.caption2)
.foregroundStyle(Color.appWarning)
Text(insight)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !data.isEmpty && chartMode == .total {
ChartStatsRow(stats: evolutionStats)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
/// One contextual takeaway recomputed for the visible window (premium).
private var rangeInsight: String? {
let series = visibleData
guard series.count >= 3 else { return nil }
var bestDate: Date?
var bestPct = -Double.infinity
for i in 1..<series.count {
let prev = NSDecimalNumber(decimal: series[i - 1].value).doubleValue
let cur = NSDecimalNumber(decimal: series[i].value).doubleValue
guard prev > 0 else { continue }
let pct = (cur - prev) / prev * 100
if pct > bestPct {
bestPct = pct
bestDate = series[i].date
}
}
guard let bestDate, bestPct.isFinite else { return nil }
return String(
format: String(localized: "chart_insight_best_month"),
Self.headerRangeFormatter.string(from: bestDate),
String(format: "%+.1f%%", bestPct)
)
}
/// Premium overlay: cumulative contributed capital vs market value shows
/// at a glance what is *your money* and what is growth.
private var contributionsToggle: some View {
Button {
if isPremium {
withAnimation(.snappy) { showContributions.toggle() }
ChartHaptics.tick()
} else {
onPremiumNeeded?()
}
} label: {
HStack(spacing: 4) {
if !isPremium {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
}
Text(String(localized: "chart_overlay_contributions"))
.font(.caption2.weight(.medium))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.foregroundStyle(showContributions ? Color.appSecondary : .secondary)
.background(
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
)
}
.buttonStyle(.plain)
}
private var cumulativeContributions: [(date: Date, value: Double)] {
var running = 0.0
return contributions
.sorted { $0.date < $1.date }
.map { item in
running += NSDecimalNumber(decimal: item.amount).doubleValue
return (item.date, running)
}
}
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),
]
}
// MARK: Live header
//
// The numbers live HERE, not on the chart: while scrubbing it shows the
// exact point (value, date, delta vs window start); at rest it summarizes
// the visible window. This is what lets the chart itself stay clean.
/// Data restricted to the currently visible window (zoom/brush).
private var visibleData: [(date: Date, value: Decimal)] {
guard let window = zoom.visibleWindow(dates: data.map(\.date)) else { return data }
let filtered = data.filter { window.contains($0.date) }
return filtered.isEmpty ? data : filtered
}
private static let headerRangeFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
return f
}()
private var headerView: some View {
let series = visibleData
let current = (chartMode == .total ? selectedDataPoint : nil)
?? series.last.map { (date: $0.date, value: $0.value) }
let firstValue = series.first.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
let currentValue = current.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
let deltaPct = firstValue > 0 ? (currentValue - firstValue) / firstValue * 100 : 0
let subtitle: String = {
if let selected = selectedDataPoint, chartMode == .total {
return selected.date.monthYearString
}
guard let first = series.first?.date, let last = series.last?.date else { return "" }
return "\(Self.headerRangeFormatter.string(from: first)) \(Self.headerRangeFormatter.string(from: last))"
}()
return HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text((current?.value ?? 0).compactCurrencyString)
.font(.system(.title2, design: .rounded).weight(.bold))
.contentTransition(.numericText())
if series.count > 1 {
Text(String(format: "%+.1f%%", deltaPct))
.font(.caption.weight(.bold))
.foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
)
}
}
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundStyle(.secondary)
}
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
}
.animation(.snappy, value: selectedDataPoint?.date)
}
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")
.foregroundStyle(.secondary)
.chartFrame(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))
}) {
// Haptic tick when the scrub snaps to a new point.
if selectedDataPoint?.date != closest.date {
ChartHaptics.tick()
}
selectedDataPoint = closest
}
}
.onEnded { _ in
selectedDataPoint = nil
}
)
}
}
}
.chartFrame(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:
// Widget-parity styling: gradient stroke, soft area fill, and NO
// permanent dots/bubbles on iPhone the endpoint dot marks "now"
// and exact values live in the live header while scrubbing.
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),
series: .value("Series", "total")
)
.foregroundStyle(
LinearGradient(colors: [Color.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing)
)
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
.interpolationMethod(.catmullRom)
if !labelsCompact {
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)
}
if labelsCompact, let last = data.last {
PointMark(
x: .value("Date", last.date),
y: .value("Value", NSDecimalNumber(decimal: last.value).doubleValue)
)
.foregroundStyle(Color.cyan)
.symbolSize(55)
}
if showContributions {
ForEach(cumulativeContributions, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Contributed", item.value),
series: .value("Series", "contributions")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 1.6, dash: [5, 4]))
.interpolationMethod(.monotone)
}
}
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.")
.foregroundStyle(.secondary)
.chartFrame(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)
.clipShape(RoundedRectangle(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)
}
}
}
}
.chartFrame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(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.")
.foregroundStyle(.secondary)
.chartFrame(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)
}
}
}
}
.chartFrame(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))
.clipShape(RoundedRectangle(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.")
.foregroundStyle(.secondary)
.chartFrame(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)
.foregroundStyle(.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)
}
}
}
}
.chartFrame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(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.")
.foregroundStyle(.secondary)
.chartFrame(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)
}
}
}
}
.chartFrame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(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))
.clipShape(RoundedRectangle(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.")
.foregroundStyle(.secondary)
.chartFrame(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)
}
}
}
}
.chartFrame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
}
#Preview {
ChartsContainerView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}