import SwiftUI import Charts 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("Charts") .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } } .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: grouped chart-type tiles + contextual filters ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(Self.chartGroups, id: \.titleKey) { group in let types = group.types.filter { showForecast || $0 != .prediction } if !types.isEmpty { Text(String(localized: String.LocalizationValue(group.titleKey))) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 6) LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) { ForEach(types) { chartType in iPadChartTypeTile(chartType) } } .padding(.horizontal, 12) } } if hasAnyFilter { Divider().padding(.vertical, 12) filtersSection .padding(.horizontal, 16) .padding(.bottom, 16) } else { Spacer().frame(height: 16) } } } .frame(width: 248) .background(Color(.systemBackground)) Divider() // Right panel: KPI header + period control + chart 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() } } } .ignoresSafeArea(edges: .bottom) } // 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]) ] // MARK: - KPI Header (iPad) /// Portfolio-level metrics for the active time range, always visible above the chart. private var kpiHeader: some View { let m = viewModel.portfolioMetrics return HStack(spacing: 10) { kpiCard(label: String(localized: "kpi_total_value"), value: m.formattedTotalValue, color: .primary) kpiCard(label: String(localized: "kpi_period_return"), value: m.formattedPercentageReturn, color: m.percentageReturn >= 0 ? .positiveGreen : .negativeRed) kpiCard(label: String(localized: "kpi_cagr"), value: m.formattedCAGR, color: m.cagr >= 0 ? .positiveGreen : .negativeRed) kpiCard(label: String(localized: "kpi_volatility"), value: m.formattedVolatility, color: .primary) kpiCard(label: String(localized: "kpi_max_drawdown"), value: m.formattedMaxDrawdown, color: .negativeRed) } } 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 !ranges.isEmpty && viewModel.selectedChartType != .performance { Picker("Period", selection: $viewModel.selectedTimeRange) { ForEach(ranges) { range in Text(range.rawValue).tag(range) } } .pickerStyle(.segmented) .frame(maxWidth: 360) } } .padding(.horizontal, 4) } /// 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) { chartTypeSelector if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) .onAppear { FirebaseService.shared.logPaywallShown(trigger: "charts_banner") } } filtersSection chartContent if viewModel.hiddenHistoryMonths > 0 { lockedHistoryTeaser } } .padding() } } } // MARK: - iPad Chart Type Tile private func iPadChartTypeTile(_ chartType: ChartsViewModel.ChartType) -> some View { let isSelected = viewModel.selectedChartType == chartType let isPremiumLocked = chartType.isPremium && !viewModel.isPremium return Button { viewModel.selectChart(chartType) } label: { VStack(spacing: 6) { ZStack(alignment: .topTrailing) { Image(systemName: chartType.icon) .font(.system(size: 18)) .foregroundColor(isSelected ? .white : .appPrimary) .frame(maxWidth: .infinity) .frame(height: 34) if isPremiumLocked { Image(systemName: "lock.fill") .font(.system(size: 9)) .foregroundColor(isSelected ? .white.opacity(0.8) : .secondary) } } Text(chartType.rawValue) .font(.caption2.weight(isSelected ? .semibold : .regular)) .foregroundColor(isSelected ? .white : .primary) .lineLimit(2) .multilineTextAlignment(.center) .minimumScaleFactor(0.8) .frame(height: 26, alignment: .top) } .padding(.vertical, 8) .padding(.horizontal, 4) .frame(maxWidth: .infinity) .background(isSelected ? Color.appPrimary : Color(.systemGray6)) .cornerRadius(10) } .buttonStyle(.plain) .accessibilityLabel(chartType.rawValue) .accessibilityAddTraits(isSelected ? [.isSelected] : []) } // MARK: - Shared private func syncState() { viewModel.selectedAccount = accountStore.selectedAccount viewModel.showAllAccounts = accountStore.showAllAccounts viewModel.loadData() goalsViewModel.selectedAccount = accountStore.selectedAccount goalsViewModel.showAllAccounts = accountStore.showAllAccounts goalsViewModel.refresh() viewModel.updatePredictionTargetDate(goalsViewModel.goals) } // MARK: - Chart Type Selector private var chartTypeSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(viewModel.availableChartTypes.filter { showForecast || $0 != .prediction }) { chartType in ChartTypeButton( chartType: chartType, isSelected: viewModel.selectedChartType == chartType, isPremium: chartType.isPremium, userIsPremium: viewModel.isPremium ) { viewModel.selectChart(chartType) } } } .padding(.horizontal, 4) } } // MARK: - Unified Filters Section private var hasAnyFilter: Bool { let chartType = viewModel.selectedChartType let isPad = horizontalSizeClass == .regular // These chart types manage their own controls internally, no external filter bar needed if chartType == .comparison || chartType == .simulator || chartType == .periodComparison { // On iPad the period picker lives in the chart toolbar, not the filter bar. return isPad ? false : !viewModel.availableTimeRanges(for: chartType).isEmpty } let hasTimeRange = !isPad && chartType != .allocation && chartType != .riskReturn let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty let hasSources = !viewModel.availableSources(for: chartType).isEmpty let hasBreakdown = chartType == .allocation || chartType == .performance return hasTimeRange || hasCategories || hasSources || hasBreakdown } @ViewBuilder private var filtersSection: some View { if hasAnyFilter { VStack(alignment: .leading, spacing: 10) { let chartType = viewModel.selectedChartType // Time Range (not for performance — uses slider). On iPad the period // picker lives in the chart toolbar, not the sidebar. if horizontalSizeClass != .regular && chartType != .allocation && chartType != .riskReturn && chartType != .performance { filterRow(icon: "calendar", label: "Period") { timeRangeSelector } } // Performance: custom period slider if chartType == .performance { filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") { Slider( value: Binding( get: { Double(viewModel.performancePeriodMonths) }, set: { viewModel.performancePeriodMonths = Int($0) } ), in: 1...60, step: 1 ) .tint(.appPrimary) } } // Breakdown (category vs source) if chartType == .allocation || chartType == .performance { filterRow(icon: "square.grid.2x2", label: "Group") { breakdownSelector } } // Category let availableCategories = viewModel.availableCategories(for: chartType) if !availableCategories.isEmpty { filterRow(icon: "tag", label: "Category") { categoryFilter } } // Source let availableSources = viewModel.availableSources(for: chartType) if !availableSources.isEmpty { filterRow(icon: "building.2", label: "Source") { sourceFilter } } } .padding(12) .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 4, y: 1) } } private func filterRow(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Image(systemName: icon) .font(.caption2) .foregroundColor(.secondary) Text(label) .font(.caption.weight(.medium)) .foregroundColor(.secondary) } content() } } // MARK: - Time Range Selector private var timeRangeSelector: some View { Group { if horizontalSizeClass == .regular { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) { ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in timeRangeButton(range, fullWidth: true) } } } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in timeRangeButton(range, fullWidth: false) } } } } } } private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View { Button { viewModel.selectedTimeRange = range } label: { Text(range.rawValue) .font(.subheadline.weight(.medium)) .frame(maxWidth: fullWidth ? .infinity : nil) .padding(.horizontal, fullWidth ? 8 : 16) .padding(.vertical, 8) .background(viewModel.selectedTimeRange == range ? Color.appPrimary : Color.gray.opacity(0.1)) .foregroundColor(viewModel.selectedTimeRange == range ? .white : .primary) .cornerRadius(20) } } // MARK: - Category Filter @ViewBuilder private var categoryFilter: some View { let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType) if !availableCategories.isEmpty { if horizontalSizeClass == .regular { VStack(spacing: 4) { if availableCategories.count > 1 { categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) { viewModel.selectedCategory = nil } } ForEach(availableCategories) { category in categoryPill(label: category.name, color: category.color, isSelected: viewModel.selectedCategory?.id == category.id) { viewModel.selectedCategory = category viewModel.selectedSource = nil } } } } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { if availableCategories.count > 1 { categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) { viewModel.selectedCategory = nil } } ForEach(availableCategories) { category in categoryPill(label: category.name, color: category.color, isSelected: viewModel.selectedCategory?.id == category.id) { viewModel.selectedCategory = category viewModel.selectedSource = nil } } } } } } } private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.caption.weight(.medium)) .frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil) .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? color : Color.gray.opacity(0.1)) .foregroundColor(isSelected ? .white : .primary) .cornerRadius(16) } } // MARK: - Source Filter @ViewBuilder private var sourceFilter: some View { let availableSources = viewModel.availableSources(for: viewModel.selectedChartType) if !availableSources.isEmpty { if horizontalSizeClass == .regular { VStack(spacing: 4) { if availableSources.count > 1 { sourcePill(label: "All Sources", color: .appPrimary, isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) { viewModel.selectedSource = nil viewModel.selectedSourceIds.removeAll() } } ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in let isSelected = viewModel.selectedSourceIds.contains(source.id) sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) { viewModel.selectedSource = nil if isSelected { viewModel.selectedSourceIds.remove(source.id) } else { viewModel.selectedSourceIds.insert(source.id) } } } } } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { if availableSources.count > 1 { sourcePill(label: "All Sources", color: .appPrimary, isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) { viewModel.selectedSource = nil viewModel.selectedSourceIds.removeAll() } } ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in let isSelected = viewModel.selectedSourceIds.contains(source.id) sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) { viewModel.selectedSource = nil if isSelected { viewModel.selectedSourceIds.remove(source.id) } else { viewModel.selectedSourceIds.insert(source.id) } } } } } } } } private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.caption.weight(.medium)) .frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil) .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? color : Color.gray.opacity(0.1)) .foregroundColor(isSelected ? .white : .primary) .cornerRadius(16) } } // MARK: - Breakdown Selector private var breakdownSelector: some View { Group { if horizontalSizeClass == .regular { VStack(spacing: 4) { ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in breakdownButton(mode) } } } else { HStack(spacing: 8) { ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in breakdownButton(mode) } } } } } private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View { Button { viewModel.selectedBreakdown = mode } label: { Text(mode.rawValue) .font(.subheadline.weight(.medium)) .frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil) .padding(.horizontal, 14) .padding(.vertical, 8) .background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1)) .foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary) .cornerRadius(18) } } private func periodLabel(_ months: Int) -> String { if months < 12 { return "\(months)M" } else if months % 12 == 0 { return "\(months / 12)Y" } else { return "\(months / 12)Y \(months % 12)M" } } // MARK: - Chart Content @ViewBuilder private var chartContent: some View { if viewModel.isLoading { ProgressView() .frame(height: 300) } else if !viewModel.hasData { emptyStateView } else { switch viewModel.selectedChartType { case .evolution: EvolutionChartView( data: viewModel.evolutionData, categoryData: viewModel.categoryEvolutionData, goals: goalsViewModel.goals ) case .allocation: VStack(spacing: 20) { AllocationPieChart( data: viewModel.allocationData, title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation", showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown) ) if !viewModel.allocationEvolutionData.isEmpty { AllocationEvolutionChart(data: viewModel.allocationEvolutionData) } } case .performance: PerformanceBarChart( data: viewModel.performanceData, title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category" ) case .contributions: ContributionsChartView(data: viewModel.contributionsData) case .rollingReturn: RollingReturnChartView(data: viewModel.rollingReturnData) case .riskReturn: RiskReturnChartView(data: viewModel.riskReturnData) case .cashflow: CashflowStackedChartView(data: viewModel.cashflowData) case .drawdown: DrawdownChart(data: viewModel.drawdownData) case .volatility: VolatilityChartView(data: viewModel.volatilityData) case .prediction: PredictionChartView( predictions: viewModel.predictionData, historicalData: viewModel.evolutionData ) case .yearOverYear: YearOverYearChartView(viewModel: viewModel) case .comparison: ComparisonChartView(viewModel: viewModel) case .simulator: AllocationSimulatorView(viewModel: viewModel) case .periodComparison: PeriodComparisonChartView(viewModel: viewModel) } } } private var emptyStateView: some View { VStack(spacing: 16) { Image(systemName: "chart.bar.xaxis") .font(.system(size: 48)) .foregroundColor(.secondary) Text("No Data Available") .font(.headline) Text("Add some investment sources and snapshots to see charts.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(height: 300) } private var accountFilterMenu: some View { Menu { Button { accountStore.selectAllAccounts() } label: { HStack { Text("All Accounts") if accountStore.showAllAccounts { Image(systemName: "checkmark") } } } Divider() ForEach(availableAccounts, id: \.objectID) { account in Button { accountStore.selectAccount(account) } label: { HStack { Text(account.name) if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts { Image(systemName: "checkmark") } } } } } label: { Image(systemName: "person.2.circle") } } private var availableAccounts: [Account] { accountStore.accounts.filter { $0.safeId != nil } } } // MARK: - Chart Type Button struct ChartTypeButton: View { let chartType: ChartsViewModel.ChartType let isSelected: Bool let isPremium: Bool let userIsPremium: Bool let action: () -> Void var body: some View { Button(action: action) { VStack(spacing: 8) { ZStack(alignment: .topTrailing) { Image(systemName: chartType.icon) .font(.title2) if isPremium && !userIsPremium { Image(systemName: "lock.fill") .font(.caption2) .foregroundColor(.appWarning) .offset(x: 8, y: -4) } } Text(chartType.rawValue) .font(.caption) } .frame(width: 80, height: 70) .background( isSelected ? LinearGradient.appPrimaryGradient : LinearGradient( colors: [ Color(.systemBackground).opacity(0.85), Color(.systemBackground).opacity(0.85) ], startPoint: .top, endPoint: .bottom ) ) .foregroundColor(isSelected ? .white : .primary) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 4, y: 2) } } } // MARK: - Evolution Chart View struct EvolutionChartView: View { let data: [(date: Date, value: Decimal)] @State private var zoom = ChartZoomModel() let categoryData: [CategoryEvolutionPoint] let goals: [Goal] @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @State private var showGoalLines = false enum ChartMode: String, CaseIterable, Identifiable { case total = "Total" case byCategory = "By Category" var id: String { rawValue } } private static let compactXAxisDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .autoupdatingCurrent formatter.setLocalizedDateFormatFromTemplate("MMM yy") return formatter }() private var xAxisMonthStride: Int { switch data.count { case ...8: return 1 case ...16: return 2 case ...30: return 3 case ...48: return 4 default: return 6 } } private var stackedCategoryData: [CategoryEvolutionPoint] { guard !categoryData.isEmpty else { return [] } let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in result[point.categoryName, default: 0] += point.value } let categoryOrder = totalsByCategory .sorted { $0.value > $1.value } .map { $0.key } let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) }) let groupedByDate = Dictionary(grouping: categoryData) { $0.date } let sortedDates = groupedByDate.keys.sorted() var stacked: [CategoryEvolutionPoint] = [] for date in sortedDates { guard let points = groupedByDate[date] else { continue } let sortedPoints = points.sorted { (orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max) } var running: Decimal = 0 for point in sortedPoints { running += point.value stacked.append(CategoryEvolutionPoint( date: point.date, categoryName: point.categoryName, colorHex: point.colorHex, value: running )) } } return stacked } var body: some View { VStack(alignment: .leading, spacing: 12) { headerView modePicker chartSection if !data.isEmpty && chartMode == .total { ChartStatsRow(stats: evolutionStats) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var evolutionStats: [ChartStat] { guard !data.isEmpty else { return [] } let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue } let latest = values.last ?? 0 let first = values.first ?? 0 let change = latest - first let changePct = first > 0 ? (change / first) * 100 : 0 let maxVal = values.max() ?? 0 let minVal = values.min() ?? 0 return [ ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary), ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen), ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed), ] } private var headerView: some View { HStack { Text("Portfolio Evolution") .font(.headline) Spacer() if let selected = selectedDataPoint, chartMode == .total { VStack(alignment: .trailing, spacing: 2) { Text(selected.value.compactCurrencyString) .font(.subheadline.weight(.semibold)) Text(selected.date.monthYearString) .font(.caption) .foregroundColor(.secondary) } } Button { showGoalLines.toggle() } label: { Image(systemName: showGoalLines ? "target" : "slash.circle") .foregroundColor(.secondary) } .disabled(goals.isEmpty) .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") } } private var modePicker: some View { Picker("Evolution Mode", selection: $chartMode) { ForEach(ChartMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.segmented) } @ViewBuilder private var chartSection: some View { if data.count >= 2 { chartView } else { Text("Not enough data") .foregroundColor(.secondary) .frame(height: 300) } } private var chartView: some View { Chart { chartMarks } .chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors) .chartXAxis { AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3])) .foregroundStyle(Color.secondary.opacity(0.2)) AxisTick(stroke: StrokeStyle(lineWidth: 0.8)) .foregroundStyle(Color.secondary.opacity(0.28)) AxisValueLabel { if let date = value.as(Date.self) { Text(date, formatter: Self.compactXAxisDateFormatter) .font(.caption2) } } } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .chartOverlay { proxy in if chartMode == .total { GeometryReader { geometry in Rectangle() .fill(.clear) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard let plotFrameAnchor = proxy.plotFrame else { return } let plotFrame = geometry[plotFrameAnchor] let x = value.location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: x) else { return } if let closest = data.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) { selectedDataPoint = closest } } .onEnded { _ in selectedDataPoint = nil } ) } } } .frame(height: 300) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Performance: Use GPU rendering for smoother scrolling on older devices .drawingGroup() } @ChartContentBuilder private var chartMarks: some ChartContent { switch chartMode { case .total: ForEach(data, id: \.date) { item in LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30) .annotation(position: .top, spacing: 3) { if data.count <= ChartLabels.alwaysShowThreshold { 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? private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } 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(data, id: \.date) { item in LineMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .symbolSize(showAllLabels ? 36 : 24) .annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) { if showAllLabels { 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)] 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() var ordered: [String] = [] for item in data { if seen.insert(item.category).inserted { ordered.append(item.category) } } return ordered } private var stableCategoryColors: [Color] { stableCategoryNames.map { name in if let hex = data.first(where: { $0.category == name })?.color { return Color(hex: hex) ?? .gray } return .gray } } private var chartView: some View { Chart(identifiableData) { item in BarMark( x: .value("Date", item.date, unit: .month), y: .value("Percentage", item.percentage) ) .foregroundStyle(by: .value("Category", item.category)) } .chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { _ in AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let pct = value.as(Double.self) { Text(String(format: "%.0f%%", pct)) .font(.caption) } } } } .frame(height: 260) .drawingGroup() } } #Preview { ChartsContainerView(iapService: IAPService()) .environmentObject(AccountStore(iapService: IAPService())) }