import SwiftUI import Charts import TipKit struct ChartsContainerView: View { @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel: ChartsViewModel @StateObject private var goalsViewModel = GoalsViewModel() @AppStorage("showForecast") private var showForecast = true @Environment(\.horizontalSizeClass) private var horizontalSizeClass init(iapService: IAPService) { _viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService)) } var body: some View { NavigationStack { Group { if horizontalSizeClass == .regular { iPadChartsLayout } else { iPhoneChartsLayout } } .navigationTitle(horizontalSizeClass == .regular ? "Charts" : viewModel.selectedChartType.rawValue) .navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .automatic : .inline) // iPhone: the title doubles as the chart switcher (Files/Freeform // pattern) — replaces the 14-chip horizontal carousel. .toolbarTitleMenu { chartTypePicker } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } ToolbarItem(placement: .navigationBarTrailing) { filterMenu } if horizontalSizeClass != .regular { ToolbarItem(placement: .navigationBarTrailing) { shareButton } } } .onAppear { syncState() } .onChange(of: accountStore.selectedAccount) { _, newAccount in viewModel.selectedAccount = newAccount goalsViewModel.selectedAccount = newAccount goalsViewModel.refresh() viewModel.updatePredictionTargetDate(goalsViewModel.goals) } .onChange(of: accountStore.showAllAccounts) { _, showAll in viewModel.showAllAccounts = showAll goalsViewModel.showAllAccounts = showAll goalsViewModel.refresh() viewModel.updatePredictionTargetDate(goalsViewModel.goals) } .onChange(of: goalsViewModel.goals) { _, goals in viewModel.updatePredictionTargetDate(goals) } } } // MARK: - iPad Layout (sidebar + full-width chart) private var iPadChartsLayout: some View { HStack(spacing: 0) { // Left sidebar: native List selection (system hit-testing, keyboard // navigation, pointer effects) — replaces the custom tile grid whose // Buttons were unreliable inside NavigationSplitView. List(selection: sidebarSelection) { ForEach(Self.chartGroups, id: \.titleKey) { group in let types = group.types.filter { showForecast || $0 != .prediction } if !types.isEmpty { Section(String(localized: String.LocalizationValue(group.titleKey))) { ForEach(types) { chartType in sidebarRow(chartType) .tag(chartType) } } } } } .listStyle(.sidebar) .frame(width: 248) Divider() // Right panel: KPI header + period control + chart. // Clipped so the decorative background can't paint over the sidebar. ZStack { AppBackground() ScrollView { VStack(spacing: 16) { kpiHeader chartToolbar if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) .onAppear { FirebaseService.shared.logPaywallShown(trigger: "charts_banner") } } chartContent if viewModel.hiddenHistoryMonths > 0 { lockedHistoryTeaser } } .padding() } } .clipped() } .ignoresSafeArea(edges: .bottom) // Attached here (not on the layout-switching Group): sheets presented from // a view that gets replaced when the size class changes were unreliable in // NavigationSplitView — premium tiles looked like they "did nothing". .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } } // MARK: - Chart groups (sidebar sections) private static let chartGroups: [(titleKey: String, types: [ChartsViewModel.ChartType])] = [ ("chart_group_overview", [.evolution, .allocation, .performance, .contributions]), ("chart_group_analyze", [.comparison, .periodComparison, .yearOverYear, .rollingReturn]), ("chart_group_risk", [.drawdown, .volatility, .riskReturn, .cashflow]), ("chart_group_forecast", [.prediction, .simulator]) ] /// Routes List selection through selectChart so premium gating (paywall) /// stays in one place; a rejected selection simply reverts on re-render. private var sidebarSelection: Binding { Binding( get: { viewModel.selectedChartType }, set: { newValue in guard let newValue else { return } viewModel.selectChart(newValue) } ) } private func sidebarRow(_ chartType: ChartsViewModel.ChartType) -> some View { HStack { Label(chartType.rawValue, systemImage: chartType.icon) Spacer() if chartType.isPremium && !viewModel.isPremium { Image(systemName: "lock.fill") .font(.caption2) .foregroundColor(.secondary) } } } /// Grouped chart switcher for the title menu (iPhone) — native Picker rows /// get the system checkmark on the active chart. private var chartTypePicker: some View { Picker("Chart", selection: sidebarSelection) { ForEach(Self.chartGroups, id: \.titleKey) { group in let types = group.types.filter { showForecast || $0 != .prediction } if !types.isEmpty { Section(String(localized: String.LocalizationValue(group.titleKey))) { ForEach(types) { chartType in Label( chartType.rawValue, systemImage: (chartType.isPremium && !viewModel.isPremium) ? "lock.fill" : chartType.icon ) .tag(ChartsViewModel.ChartType?.some(chartType)) } } } } } } // MARK: - Filters (toolbar menu) private var hasActiveFilters: Bool { let breakdownApplies = viewModel.selectedChartType == .allocation || viewModel.selectedChartType == .performance return viewModel.selectedCategory != nil || !viewModel.selectedSourceIds.isEmpty || (breakdownApplies && viewModel.selectedBreakdown != .category) } @ViewBuilder private var filterMenu: some View { let chartType = viewModel.selectedChartType let categories = viewModel.availableCategories(for: chartType) let sources = viewModel.availableSources(for: chartType) let hasBreakdown = chartType == .allocation || chartType == .performance if hasBreakdown || !categories.isEmpty || !sources.isEmpty { Menu { if hasBreakdown { Picker("Group", selection: $viewModel.selectedBreakdown) { ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.inline) } if !categories.isEmpty { Picker("Category", selection: categorySelection) { Text("All Categories").tag(Category?.none) ForEach(categories) { category in Text(category.name).tag(Optional(category)) } } .pickerStyle(.menu) } if !sources.isEmpty { Menu("Sources") { ForEach(sources) { source in Toggle(source.name, isOn: sourceToggle(source)) } if !viewModel.selectedSourceIds.isEmpty { Divider() Button("Show All Sources") { viewModel.selectedSource = nil viewModel.selectedSourceIds.removeAll() } } } } if hasActiveFilters { Divider() Button("Reset Filters", role: .destructive) { viewModel.selectedCategory = nil viewModel.selectedSource = nil viewModel.selectedSourceIds.removeAll() viewModel.selectedBreakdown = .category } } } label: { Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } .accessibilityLabel("Filters") } } private var categorySelection: Binding { Binding( get: { viewModel.selectedCategory }, set: { newValue in viewModel.selectedCategory = newValue viewModel.selectedSource = nil } ) } private func sourceToggle(_ source: InvestmentSource) -> Binding { Binding( get: { viewModel.selectedSourceIds.contains(source.id) }, set: { isOn in viewModel.selectedSource = nil if isOn { viewModel.selectedSourceIds.insert(source.id) } else { viewModel.selectedSourceIds.remove(source.id) } } ) } // MARK: - KPI Header (iPad) /// Chart-specific metrics above the chart. Each chart type surfaces the KPIs /// that make sense for it (the same ones its in-card stats row shows on /// iPhone) instead of the always-identical portfolio aggregates. private var selectedChartLocked: Bool { viewModel.selectedChartType.isPremium && !viewModel.isPremium } @ViewBuilder private var kpiHeader: some View { let stats = selectedChartLocked ? [] : chartSpecificStats if !stats.isEmpty { HStack(spacing: 10) { ForEach(stats.indices, id: \.self) { i in kpiCard(label: stats[i].label, value: stats[i].value, color: stats[i].color) } } } } private var chartSpecificStats: [ChartStat] { switch viewModel.selectedChartType { case .evolution: let data = viewModel.evolutionData guard !data.isEmpty else { return [] } let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue } let latest = values.last ?? 0 let first = values.first ?? 0 let changePct = first > 0 ? ((latest - first) / first) * 100 : 0 let m = viewModel.portfolioMetrics return [ ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary), ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "CAGR", value: m.formattedCAGR, color: m.cagr >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Max", value: Decimal(values.max() ?? 0).compactCurrencyString, color: .positiveGreen), ChartStat(label: "Min", value: Decimal(values.min() ?? 0).compactCurrencyString, color: .negativeRed), ] case .allocation: let data = viewModel.allocationData guard !data.isEmpty else { return [] } let total = data.reduce(Decimal.zero) { $0 + $1.value } let top = data[0] let topPct = total > 0 ? NSDecimalNumber(decimal: top.value / total * 100).doubleValue : 0 return [ ChartStat(label: "Total", value: total.compactCurrencyString, color: .primary), ChartStat(label: "Largest", value: top.category, color: Color(hex: top.color) ?? .appPrimary), ChartStat(label: "Top share", value: String(format: "%.1f%%", topPct), color: .appPrimary), ChartStat(label: "Positions", value: "\(data.count)", color: .secondary), ] case .performance: let data = viewModel.performanceData guard !data.isEmpty else { return [] } let avg = data.map { $0.cagr }.reduce(0, +) / Double(data.count) let best = data.max(by: { $0.cagr < $1.cagr }) let worst = data.min(by: { $0.cagr < $1.cagr }) return [ ChartStat(label: "Best", value: best.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .positiveGreen), ChartStat(label: "Worst", value: worst.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .negativeRed), ChartStat(label: "Average", value: String(format: "%+.1f%%", avg), color: .secondary), ] case .contributions: let data = viewModel.contributionsData guard !data.isEmpty else { return [] } let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue } let total = amounts.reduce(0, +) return [ ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary), ChartStat(label: "Monthly avg", value: Decimal(total / Double(amounts.count)).compactCurrencyString, color: .secondary), ChartStat(label: "Highest", value: Decimal(amounts.max() ?? 0).compactCurrencyString, color: .positiveGreen), ChartStat(label: "Last", value: Decimal(amounts.last ?? 0).compactCurrencyString, color: .appPrimary), ] case .rollingReturn: let values = viewModel.rollingReturnData.map { $0.value } guard !values.isEmpty else { return [] } let latest = values.last ?? 0 let avg = values.reduce(0, +) / Double(values.count) return [ ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Best", value: String(format: "%.1f%%", values.max() ?? 0), color: .positiveGreen), ChartStat(label: "Worst", value: String(format: "%.1f%%", values.min() ?? 0), color: .negativeRed), ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), ] case .riskReturn: let data = viewModel.riskReturnData guard !data.isEmpty else { return [] } let bestReturn = data.max(by: { $0.cagr < $1.cagr }) let lowestRisk = data.min(by: { $0.volatility < $1.volatility }) return [ ChartStat(label: "Best return", value: bestReturn.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .positiveGreen), ChartStat(label: "Lowest risk", value: lowestRisk.map { "\($0.category) \(String(format: "%.1f%%", $0.volatility))" } ?? "—", color: .appPrimary), ChartStat(label: "Categories", value: "\(data.count)", color: .secondary), ] case .cashflow: let data = viewModel.cashflowData guard !data.isEmpty else { return [] } let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions } let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance } let net = totalContrib + totalPerf return [ ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary), ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed), ] case .drawdown: let values = viewModel.drawdownData.map { $0.drawdown } guard !values.isEmpty else { return [] } let current = values.last ?? 0 let worst = values.min() ?? 0 let avg = values.reduce(0, +) / Double(values.count) return [ ChartStat(label: "Current", value: String(format: "%.1f%%", current), color: current >= -1 ? .positiveGreen : .negativeRed), ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed), ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), ] case .volatility: let values = viewModel.volatilityData.map { $0.volatility } guard !values.isEmpty else { return [] } let avg = values.reduce(0, +) / Double(values.count) return [ ChartStat(label: "Current", value: String(format: "%.1f%%", values.last ?? 0), color: .appPrimary), ChartStat(label: "Max", value: String(format: "%.1f%%", values.max() ?? 0), color: .negativeRed), ChartStat(label: "Min", value: String(format: "%.1f%%", values.min() ?? 0), color: .positiveGreen), ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), ] case .prediction: guard let lastPred = viewModel.predictionData.last else { return [] } let current = viewModel.evolutionData.last?.value ?? 0 return [ ChartStat(label: "Current", value: current.compactCurrencyString, color: .appPrimary), ChartStat(label: "Base (\(viewModel.predictionData.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary), ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen), ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed), ] case .comparison: let series = viewModel.comparisonData guard !series.isEmpty else { return [] } // Rank sources by their latest value in the current display mode. let ranked = series.compactMap { s -> (name: String, val: Double)? in guard let last = s.points.last?.value else { return nil } return (s.name, last) }.sorted { $0.val > $1.val } let unit = viewModel.comparisonDisplayMode == .absolute ? "" : "%" func fmt(_ v: Double) -> String { unit.isEmpty ? Decimal(v).compactCurrencyString : String(format: "%+.1f%%", v) } var stats: [ChartStat] = [ChartStat(label: "Series", value: "\(series.count)", color: .secondary)] if let best = ranked.first { stats.append(ChartStat(label: "Leader", value: "\(best.name) \(fmt(best.val))", color: .positiveGreen)) } if ranked.count > 1, let worst = ranked.last { stats.append(ChartStat(label: "Laggard", value: "\(worst.name) \(fmt(worst.val))", color: .negativeRed)) } return stats case .periodComparison: let series = viewModel.periodComparisonData guard !series.isEmpty else { return [] } return series.map { s in let end = s.points.last?.returnPct ?? 0 return ChartStat(label: s.label, value: String(format: "%+.1f%%", end), color: end >= 0 ? .positiveGreen : .negativeRed) } case .yearOverYear: let all = viewModel.yearOverYearData.sorted { $0.year < $1.year } guard let latest = all.last else { return [] } func endReturn(_ ys: ChartsViewModel.YearSeries) -> Double { ys.forecastEndValue ?? (ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0) } let latestEnd = endReturn(latest) var stats: [ChartStat] = [ ChartStat(label: "\(latest.year)", value: String(format: "%+.1f%%", latestEnd), color: latestEnd >= 0 ? .positiveGreen : .negativeRed) ] // Current year vs previous year (end-of-year return difference). if all.count >= 2 { let prev = all[all.count - 2] let diff = latestEnd - endReturn(prev) stats.append(ChartStat( label: String(format: String(localized: "yoy_vs_prev"), "\(prev.year)"), value: String(format: "%+.1f pp", diff), color: diff >= 0 ? .positiveGreen : .negativeRed)) } // Best and worst full year on record. if all.count >= 2 { let ranked = all.map { ($0.year, endReturn($0)) }.sorted { $0.1 > $1.1 } if let best = ranked.first { stats.append(ChartStat(label: String(localized: "yoy_best_year"), value: "\(best.0) \(String(format: "%+.1f%%", best.1))", color: .positiveGreen)) } if let worst = ranked.last, worst.0 != ranked.first?.0 { stats.append(ChartStat(label: String(localized: "yoy_worst_year"), value: "\(worst.0) \(String(format: "%+.1f%%", worst.1))", color: .negativeRed)) } } return stats case .simulator: // The simulator carries its own live sliders and comparison inline. return [] } } private func kpiCard(label: String, value: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) Text(value) .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundColor(color) .lineLimit(1) .minimumScaleFactor(0.6) } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.smallCornerRadius) .shadow(color: .black.opacity(0.05), radius: 4, y: 1) } // MARK: - Chart toolbar (iPad): title + native segmented period picker @ViewBuilder private var chartToolbar: some View { let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType) HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(viewModel.selectedChartType.rawValue) .font(.headline) Text(viewModel.selectedChartType.description) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) } Spacer() if viewModel.selectedChartType == .performance { performancePeriodPicker .frame(maxWidth: 360) } else if !ranges.isEmpty { Picker("Period", selection: $viewModel.selectedTimeRange) { ForEach(ranges) { range in Text(range.rawValue).tag(range) } } .pickerStyle(.segmented) .frame(maxWidth: 360) } shareButton } .padding(.horizontal, 4) } /// Months of history actually available (distinct aggregated months). The /// Performance period options never exceed this, so the control can't ask /// for a window with no data. private var availableHistoryMonths: Int { max(1, viewModel.evolutionData.count) } /// Discrete period options (in months) capped to available data — mirrors /// the segmented period control used by every other chart for consistency. private var performancePeriodOptions: [(label: String, months: Int)] { let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")] var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) } opts.append(("All", availableHistoryMonths)) // full history return opts.map { (label: $0.0, months: $0.1) } } private var performancePeriodPicker: some View { Picker("Period", selection: Binding( get: { // Snap to the closest available option. let m = viewModel.performancePeriodMonths return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months ?? availableHistoryMonths }, set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) } )) { ForEach(performancePeriodOptions, id: \.months) { opt in Text(opt.label).tag(opt.months) } } .pickerStyle(.segmented) } private var shareButton: some View { Button { ChartShareTip().invalidate(reason: .actionPerformed) shareCurrentChart() } label: { Image(systemName: "square.and.arrow.up") } .popoverTip(ChartShareTip()) .disabled(!viewModel.hasData || viewModel.isLoading || selectedChartLocked) .accessibilityLabel(String(localized: "chart_share_button")) } private func shareCurrentChart() { ChartShareService.shared.share( title: viewModel.selectedChartType.rawValue, subtitle: viewModel.selectedChartType.description, stats: chartSpecificStats ) { chartContent .environmentObject(accountStore) .frame(width: 672) } } /// Concrete loss-framing upsell: the user has real data older than the free /// 12-month window; tell them exactly how much is locked instead of hiding it. private var lockedHistoryTeaser: some View { Button { FirebaseService.shared.logPaywallShown(trigger: "history_teaser") viewModel.showingPaywall = true } label: { HStack(spacing: 8) { Image(systemName: "lock.fill") Text(String(format: String(localized: "chart_history_locked"), viewModel.hiddenHistoryMonths)) .multilineTextAlignment(.leading) Spacer() Image(systemName: "chevron.right") .font(.caption) } .font(.footnote.weight(.medium)) .foregroundColor(.appPrimary) .padding(12) .background(Color.appPrimary.opacity(0.08)) .cornerRadius(AppConstants.UI.smallCornerRadius) } .buttonStyle(.plain) } // MARK: - iPhone Layout private var iPhoneChartsLayout: some View { ZStack { AppBackground() ScrollView { VStack(spacing: 20) { if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) .onAppear { FirebaseService.shared.logPaywallShown(trigger: "charts_banner") } } iPhonePeriodControl chartContent // Swipe left/right to move to the adjacent chart type. // simultaneousGesture so charts with their own scrub drag // don't swallow the swipe. .simultaneousGesture(chartSwipeGesture) .id(viewModel.selectedChartType) .transition(.asymmetric( insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity), removal: .opacity )) chartPageIndicator if viewModel.hiddenHistoryMonths > 0 { lockedHistoryTeaser } } .padding() } } .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } } // MARK: - iPhone swipe between charts /// 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 } } @State private var swipeInsertionEdge: Edge = .trailing private var chartSwipeGesture: some Gesture { DragGesture(minimumDistance: 30) .onEnded { value in // Horizontal-dominant swipes only, so vertical scroll still works. guard abs(value.translation.width) > abs(value.translation.height) * 1.5 else { return } let ordered = orderedChartTypes guard let idx = ordered.firstIndex(of: viewModel.selectedChartType) else { return } if value.translation.width < 0, idx < ordered.count - 1 { swipeInsertionEdge = .trailing withAnimation(.snappy) { viewModel.selectChart(ordered[idx + 1]) } } else if value.translation.width > 0, idx > 0 { swipeInsertionEdge = .leading withAnimation(.snappy) { viewModel.selectChart(ordered[idx - 1]) } } } } private var chartPageIndicator: some View { let ordered = orderedChartTypes let current = ordered.firstIndex(of: viewModel.selectedChartType) ?? 0 return HStack(spacing: 5) { ForEach(ordered.indices, id: \.self) { i in Circle() .fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25)) .frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5) } } .animation(.snappy, value: current) } /// Stocks-style segmented period control above the chart (the chart switcher /// lives in the title menu, filters in the toolbar). @ViewBuilder private var iPhonePeriodControl: some View { let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType) if viewModel.selectedChartType == .performance { performancePeriodPicker } else if !ranges.isEmpty { Picker("Period", selection: $viewModel.selectedTimeRange) { ForEach(ranges) { range in Text(range.rawValue).tag(range) } } .pickerStyle(.segmented) } } // MARK: - Shared private func syncState() { viewModel.selectedAccount = accountStore.selectedAccount viewModel.showAllAccounts = accountStore.showAllAccounts viewModel.loadData() goalsViewModel.selectedAccount = accountStore.selectedAccount goalsViewModel.showAllAccounts = accountStore.showAllAccounts goalsViewModel.refresh() viewModel.updatePredictionTargetDate(goalsViewModel.goals) } private func periodLabel(_ months: Int) -> String { if months < 12 { return "\(months)M" } else if months % 12 == 0 { return "\(months / 12)Y" } else { return "\(months / 12)Y \(months % 12)M" } } // MARK: - Chart Content @ViewBuilder private var chartContent: some View { if viewModel.selectedChartType.isPremium && !viewModel.isPremium { premiumLockedView } else if viewModel.isLoading { ProgressView() .frame(height: 300) } else if !viewModel.hasData { emptyStateView } else { switch viewModel.selectedChartType { case .evolution: EvolutionChartView( data: viewModel.evolutionData, categoryData: viewModel.categoryEvolutionData, goals: goalsViewModel.goals ) case .allocation: VStack(spacing: 20) { AllocationPieChart( data: viewModel.allocationData, title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation", showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown) ) if !viewModel.allocationEvolutionData.isEmpty { AllocationEvolutionChart(data: viewModel.allocationEvolutionData) } } case .performance: PerformanceBarChart( data: viewModel.performanceData, title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category" ) case .contributions: ContributionsChartView(data: viewModel.contributionsData) case .rollingReturn: RollingReturnChartView(data: viewModel.rollingReturnData) case .riskReturn: RiskReturnChartView(data: viewModel.riskReturnData) case .cashflow: CashflowStackedChartView(data: viewModel.cashflowData) case .drawdown: DrawdownChart(data: viewModel.drawdownData) case .volatility: VolatilityChartView(data: viewModel.volatilityData) case .prediction: PredictionChartView( predictions: viewModel.predictionData, historicalData: viewModel.evolutionData ) case .yearOverYear: YearOverYearChartView(viewModel: viewModel) case .comparison: ComparisonChartView(viewModel: viewModel) case .simulator: AllocationSimulatorView(viewModel: viewModel) case .periodComparison: PeriodComparisonChartView(viewModel: viewModel) } } } /// Shown in place of a premium chart when the user isn't premium. Selecting /// the chart always works; unlocking is an explicit button tap (a reliable /// context for presenting the paywall sheet). private var premiumLockedView: some View { VStack(spacing: 16) { ZStack { Circle() .fill(Color.appPrimary.opacity(0.12)) .frame(width: 88, height: 88) Image(systemName: viewModel.selectedChartType.icon) .font(.system(size: 34)) .foregroundColor(.appPrimary) Image(systemName: "lock.circle.fill") .font(.system(size: 26)) .foregroundColor(.appWarning) .background(Circle().fill(Color(.systemBackground))) .offset(x: 32, y: 30) } Text(String(format: String(localized: "chart_locked_title"), viewModel.selectedChartType.rawValue)) .font(.headline) Text(viewModel.selectedChartType.description) .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) Button { FirebaseService.shared.logPaywallShown(trigger: "advanced_charts") viewModel.showingPaywall = true } label: { Text(String(localized: "chart_locked_cta")) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Color.appPrimary) .foregroundColor(.white) .cornerRadius(24) } } .padding(32) .frame(maxWidth: .infinity, minHeight: 320) .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var emptyStateView: some View { VStack(spacing: 16) { Image(systemName: "chart.bar.xaxis") .font(.system(size: 48)) .foregroundColor(.secondary) Text("No Data Available") .font(.headline) Text("Add some investment sources and snapshots to see charts.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(height: 300) } private var accountFilterMenu: some View { Menu { Button { accountStore.selectAllAccounts() } label: { HStack { Text("All Accounts") if accountStore.showAllAccounts { Image(systemName: "checkmark") } } } Divider() ForEach(availableAccounts, id: \.objectID) { account in Button { accountStore.selectAccount(account) } label: { HStack { Text(account.name) if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts { Image(systemName: "checkmark") } } } } } label: { Image(systemName: "person.2.circle") } } private var availableAccounts: [Account] { accountStore.accounts.filter { $0.safeId != nil } } } // MARK: - Evolution Chart View struct EvolutionChartView: View { let data: [(date: Date, value: Decimal)] @State private var zoom = ChartZoomModel() let categoryData: [CategoryEvolutionPoint] let goals: [Goal] @Environment(\.chartImageExport) private var chartImageExport @Environment(\.horizontalSizeClass) private var labelsSizeClass private var labelsCompact: Bool { labelsSizeClass != .regular } @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @State private var showGoalLines = false enum ChartMode: String, CaseIterable, Identifiable { case total = "Total" case byCategory = "By Category" var id: String { rawValue } } private static let compactXAxisDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .autoupdatingCurrent formatter.setLocalizedDateFormatFromTemplate("MMM yy") return formatter }() private var xAxisMonthStride: Int { switch data.count { case ...8: return 1 case ...16: return 2 case ...30: return 3 case ...48: return 4 default: return 6 } } private var stackedCategoryData: [CategoryEvolutionPoint] { guard !categoryData.isEmpty else { return [] } let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in result[point.categoryName, default: 0] += point.value } let categoryOrder = totalsByCategory .sorted { $0.value > $1.value } .map { $0.key } let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) }) let groupedByDate = Dictionary(grouping: categoryData) { $0.date } let sortedDates = groupedByDate.keys.sorted() var stacked: [CategoryEvolutionPoint] = [] for date in sortedDates { guard let points = groupedByDate[date] else { continue } let sortedPoints = points.sorted { (orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max) } var running: Decimal = 0 for point in sortedPoints { running += point.value stacked.append(CategoryEvolutionPoint( date: point.date, categoryName: point.categoryName, colorHex: point.colorHex, value: running )) } } return stacked } var body: some View { VStack(alignment: .leading, spacing: 12) { headerView modePicker chartSection if !data.isEmpty && chartMode == .total { ChartStatsRow(stats: evolutionStats) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var evolutionStats: [ChartStat] { guard !data.isEmpty else { return [] } let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue } let latest = values.last ?? 0 let first = values.first ?? 0 let change = latest - first let changePct = first > 0 ? (change / first) * 100 : 0 let maxVal = values.max() ?? 0 let minVal = values.min() ?? 0 return [ ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary), ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen), ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed), ] } private var headerView: some View { HStack { Text("Portfolio Evolution") .font(.headline) Spacer() if let selected = selectedDataPoint, chartMode == .total { VStack(alignment: .trailing, spacing: 2) { Text(selected.value.compactCurrencyString) .font(.subheadline.weight(.semibold)) Text(selected.date.monthYearString) .font(.caption) .foregroundColor(.secondary) } } Button { showGoalLines.toggle() } label: { Image(systemName: showGoalLines ? "target" : "slash.circle") .foregroundColor(.secondary) } .disabled(goals.isEmpty) .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") } } private var modePicker: some View { Picker("Evolution Mode", selection: $chartMode) { ForEach(ChartMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.segmented) } @ViewBuilder private var chartSection: some View { if data.count >= 2 { chartView } else { Text("Not enough data") .foregroundColor(.secondary) .frame(height: 300) } } private var chartView: some View { Chart { chartMarks } .chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors) .chartXAxis { AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3])) .foregroundStyle(Color.secondary.opacity(0.2)) AxisTick(stroke: StrokeStyle(lineWidth: 0.8)) .foregroundStyle(Color.secondary.opacity(0.28)) AxisValueLabel { if let date = value.as(Date.self) { Text(date, formatter: Self.compactXAxisDateFormatter) .font(.caption2) } } } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .chartOverlay { proxy in if chartMode == .total { GeometryReader { geometry in Rectangle() .fill(.clear) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard let plotFrameAnchor = proxy.plotFrame else { return } let plotFrame = geometry[plotFrameAnchor] let x = value.location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: x) else { return } if let closest = data.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) { selectedDataPoint = closest } } .onEnded { _ in selectedDataPoint = nil } ) } } } .frame(height: 300) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Performance: GPU rendering for smoother scrolling on older devices .chartDrawingGroup(disabledForExport: chartImageExport) } @ChartContentBuilder private var chartMarks: some ChartContent { switch chartMode { case .total: ForEach(Array(data.enumerated()), id: \.element.date) { pair in let item = pair.element LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .symbolSize(30) .annotation(position: .top, spacing: 3) { if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: item.value.compactCurrencyString) } } AreaMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle( LinearGradient( colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], startPoint: .top, endPoint: .bottom ) ) .interpolationMethod(.catmullRom) } case .byCategory: ForEach(categoryData) { item in AreaMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .interpolationMethod(.catmullRom) .opacity(0.5) } ForEach(stackedCategoryData) { item in LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .symbolSize(18) } } if showGoalLines { ForEach(goals) { goal in RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue)) .foregroundStyle(Color.appSecondary.opacity(0.4)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4])) } } // Scrub/tap selection bubble (total mode) if chartMode == .total, let sel = selectedDataPoint { RuleMark(x: .value("Selected", sel.date)) .foregroundStyle(Color.secondary.opacity(0.35)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) PointMark( x: .value("Selected", sel.date), y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .symbolSize(80) .annotation(position: .top, spacing: 4) { ChartValueBubble(text: sel.value.compactCurrencyString, prominent: true) } } } private var chartCategoryNames: [String] { let names = Array(Set(categoryData.map { $0.categoryName })).sorted() return names } private var chartCategoryColors: [Color] { chartCategoryNames.map { name in if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex { return Color(hex: hex) ?? .gray } return .gray } } } // MARK: - Contributions Chart struct ContributionsChartView: View { let data: [(date: Date, amount: Decimal)] @State private var zoom = ChartZoomModel() var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Contributions") .font(.headline) if data.isEmpty { Text("No contributions yet.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in BarMark( x: .value("Month", item.date), y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue) ) .foregroundStyle(Color.appSecondary) .cornerRadius(6) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .frame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: contributionStats).padding(.top, 4) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var contributionStats: [ChartStat] { guard !data.isEmpty else { return [] } let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue } let total = amounts.reduce(0, +) let avg = total / Double(amounts.count) let highest = amounts.max() ?? 0 let last = amounts.last ?? 0 return [ ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary), ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary), ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen), ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary), ] } } // MARK: - Rolling 12-Month Return struct RollingReturnChartView: View { let data: [(date: Date, value: Double)] @State private var zoom = ChartZoomModel() @State private var selectedDate: Date? @Environment(\.horizontalSizeClass) private var labelsSizeClass private var labelsCompact: Bool { labelsSizeClass != .regular } private var selectedPoint: (date: Date, value: Double)? { guard let selectedDate else { return nil } return data.min(by: { abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) }) } var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Rolling 12-Month Return") .font(.headline) if data.isEmpty { Text("Not enough data for rolling returns.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(Array(data.enumerated()), id: \.element.date) { pair in let item = pair.element LineMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .symbolSize(24) .annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) { if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: ChartLabels.percent(item.value)) } } } if let sel = selectedPoint { RuleMark(x: .value("Selected", sel.date)) .foregroundStyle(Color.secondary.opacity(0.35)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) PointMark( x: .value("Selected", sel.date), y: .value("Return", sel.value) ) .foregroundStyle(Color.appPrimary) .symbolSize(70) .annotation(position: sel.value >= 0 ? .top : .bottom, spacing: 4) { ChartValueBubble(text: ChartLabels.percent(sel.value), prominent: true) } } } .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .frame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: rollingStats).padding(.top, 4) ChartDataTable( rows: rollingTableRows, valueHeader: "Return", deltaPrevHeader: "Δ prev", deltaFirstHeader: "Δ first" ) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var rollingStats: [ChartStat] { guard !data.isEmpty else { return [] } let values = data.map { $0.value } let latest = values.last ?? 0 let best = values.max() ?? 0 let worst = values.min() ?? 0 let avg = values.reduce(0, +) / Double(values.count) let change = (values.last ?? 0) - (values.first ?? 0) return [ ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen), ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed), ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed), ] } private var rollingTableRows: [ChartDataTableRow] { guard !data.isEmpty else { return [] } let firstVal = data.first?.value ?? 0 return data.enumerated().map { idx, point in let prevVal = idx > 0 ? data[idx - 1].value : point.value let deltaPrev = point.value - prevVal let deltaFirst = point.value - firstVal return ChartDataTableRow( label: chartTableDateLabel(point.date), value: String(format: "%.1f%%", point.value), deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil, deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil, isPrevPositive: deltaPrev >= 0, isFirstPositive: deltaFirst >= 0 ) } } } // MARK: - Risk vs Return struct RiskReturnChartView: View { let data: [(category: String, cagr: Double, volatility: Double, color: String)] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Risk vs Return") .font(.headline) if data.isEmpty { Text("Not enough data to compare categories.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.category) { item in PointMark( x: .value("Volatility", item.volatility), y: .value("CAGR", item.cagr) ) .foregroundStyle(Color(hex: item.color) ?? .gray) .symbolSize(60) .annotation(position: .top) { Text(item.category) .font(.caption2) .foregroundColor(.secondary) } } } .chartXAxis { AxisMarks(position: .bottom) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .frame(height: 260) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } // MARK: - Net Performance vs Contributions struct CashflowStackedChartView: View { let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] @State private var zoom = ChartZoomModel() var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Net Performance vs Contributions") .font(.headline) if data.isEmpty { Text("Not enough data to compare cashflow.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue let stackedEnd = contributionValue + netValue BarMark( x: .value("Month", item.date), yStart: .value("Start", 0), yEnd: .value("Contributions", contributionValue) ) .foregroundStyle(Color.appSecondary.opacity(0.8)) BarMark( x: .value("Month", item.date), yStart: .value("Start", contributionValue), yEnd: .value("Net", stackedEnd) ) .foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .frame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: cashflowStats).padding(.top, 4) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var cashflowStats: [ChartStat] { guard !data.isEmpty else { return [] } let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions } let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance } let net = totalContrib + totalPerf return [ ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary), ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed), ] } } // MARK: - Allocation Evolution Chart struct AllocationEvolutionDataPoint: Identifiable { let id: String let date: Date let category: String let percentage: Double let color: String } struct AllocationEvolutionChart: View { let data: [(date: Date, category: String, percentage: Double, color: String)] @Environment(\.chartImageExport) private var chartImageExport private var identifiableData: [AllocationEvolutionDataPoint] { data.enumerated().map { index, item in AllocationEvolutionDataPoint( id: "\(index)-\(item.category)", date: item.date, category: item.category, percentage: item.percentage, color: item.color ) } } var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Allocation Over Time") .font(.headline) if data.isEmpty { emptyView } else { chartView } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var emptyView: some View { Text("Not enough data to show allocation evolution.") .foregroundColor(.secondary) .frame(height: 260) } /// Categories in stable order (preserving the ViewModel's sort: largest overall first) private var stableCategoryNames: [String] { var seen = Set() var ordered: [String] = [] for item in data { if seen.insert(item.category).inserted { ordered.append(item.category) } } return ordered } private var stableCategoryColors: [Color] { stableCategoryNames.map { name in if let hex = data.first(where: { $0.category == name })?.color { return Color(hex: hex) ?? .gray } return .gray } } private var chartView: some View { Chart(identifiableData) { item in BarMark( x: .value("Date", item.date, unit: .month), y: .value("Percentage", item.percentage) ) .foregroundStyle(by: .value("Category", item.category)) } .chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { _ in AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let pct = value.as(Double.self) { Text(String(format: "%.0f%%", pct)) .font(.caption) } } } } .frame(height: 260) .chartDrawingGroup(disabledForExport: chartImageExport) } } #Preview { ChartsContainerView(iapService: IAPService()) .environmentObject(AccountStore(iapService: IAPService())) }