import SwiftUI import Charts struct ChartsContainerView: View { @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel: ChartsViewModel @StateObject private var goalsViewModel = GoalsViewModel() @AppStorage("calmModeEnabled") private var calmModeEnabled = true init(iapService: IAPService) { _viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService)) } var body: some View { NavigationStack { ZStack { AppBackground() ScrollView { VStack(spacing: 20) { // Chart Type Selector chartTypeSelector // Time Range Selector if viewModel.selectedChartType != .allocation && viewModel.selectedChartType != .performance && viewModel.selectedChartType != .riskReturn { timeRangeSelector } // Category Filter if viewModel.selectedChartType == .evolution || viewModel.selectedChartType == .prediction { categoryFilter } // Chart Content chartContent } .padding() } } .navigationTitle("Charts") .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } } .onAppear { viewModel.selectedAccount = accountStore.selectedAccount viewModel.showAllAccounts = accountStore.showAllAccounts viewModel.loadData() goalsViewModel.selectedAccount = accountStore.selectedAccount goalsViewModel.showAllAccounts = accountStore.showAllAccounts goalsViewModel.refresh() viewModel.updatePredictionTargetDate(goalsViewModel.goals) } // Performance: Use onChange instead of onReceive for cleaner state updates .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: - Chart Type Selector private var chartTypeSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in ChartTypeButton( chartType: chartType, isSelected: viewModel.selectedChartType == chartType, isPremium: chartType.isPremium, userIsPremium: viewModel.isPremium ) { viewModel.selectChart(chartType) } } } .padding(.horizontal, 4) } } // MARK: - Time Range Selector private var timeRangeSelector: some View { HStack(spacing: 8) { ForEach(ChartsViewModel.TimeRange.allCases) { range in Button { viewModel.selectedTimeRange = range } label: { Text(range.rawValue) .font(.subheadline.weight(.medium)) .padding(.horizontal, 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 { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { if availableCategories.count > 1 { Button { viewModel.selectedCategory = nil } label: { Text("All") .font(.caption.weight(.medium)) .padding(.horizontal, 12) .padding(.vertical, 6) .background( viewModel.selectedCategory == nil ? Color.appPrimary : Color.gray.opacity(0.1) ) .foregroundColor( viewModel.selectedCategory == nil ? .white : .primary ) .cornerRadius(16) } } ForEach(availableCategories) { category in Button { viewModel.selectedCategory = category } label: { HStack(spacing: 4) { Circle() .fill(category.color) .frame(width: 8, height: 8) Text(category.name) } .font(.caption.weight(.medium)) .padding(.horizontal, 12) .padding(.vertical, 6) .background( viewModel.selectedCategory?.id == category.id ? category.color : Color.gray.opacity(0.1) ) .foregroundColor( viewModel.selectedCategory?.id == category.id ? .white : .primary ) .cornerRadius(16) } } } } } } // 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: AllocationPieChart(data: viewModel.allocationData) case .performance: PerformanceBarChart(data: viewModel.performanceData) 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 ) } } } 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(accountStore.accounts) { account in Button { accountStore.selectAccount(account) } label: { HStack { Text(account.name) if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts { Image(systemName: "checkmark") } } } } } label: { Image(systemName: "person.2.circle") } } } // MARK: - Chart Type Button struct ChartTypeButton: View { let chartType: ChartsViewModel.ChartType let isSelected: Bool let isPremium: Bool let userIsPremium: Bool let action: () -> Void var body: some View { Button(action: action) { VStack(spacing: 8) { ZStack(alignment: .topTrailing) { Image(systemName: chartType.icon) .font(.title2) if isPremium && !userIsPremium { Image(systemName: "lock.fill") .font(.caption2) .foregroundColor(.appWarning) .offset(x: 8, y: -4) } } Text(chartType.rawValue) .font(.caption) } .frame(width: 80, height: 70) .background( isSelected ? LinearGradient.appPrimaryGradient : LinearGradient( colors: [ Color(.systemBackground).opacity(0.85), Color(.systemBackground).opacity(0.85) ], startPoint: .top, endPoint: .bottom ) ) .foregroundColor(isSelected ? .white : .primary) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 4, y: 2) } } } // MARK: - Evolution Chart View struct EvolutionChartView: View { let data: [(date: Date, value: Decimal)] let categoryData: [CategoryEvolutionPoint] let goals: [Goal] @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @State private var showGoalLines = false enum ChartMode: String, CaseIterable, Identifiable { case total = "Total" case byCategory = "By Category" var id: String { rawValue } } private 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 } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } 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: 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) } } } } .chartOverlay { proxy in if chartMode == .total { GeometryReader { geometry in Rectangle() .fill(.clear) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard let plotFrameAnchor = proxy.plotFrame else { return } let plotFrame = geometry[plotFrameAnchor] let x = value.location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: x) else { return } if let closest = data.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) { selectedDataPoint = closest } } .onEnded { _ in selectedDataPoint = nil } ) } } } .frame(height: 300) // Performance: Use GPU rendering for smoother scrolling on older devices .drawingGroup() } @ChartContentBuilder private var chartMarks: some ChartContent { switch chartMode { case .total: ForEach(data, id: \.date) { item in LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .symbolSize(30) AreaMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle( LinearGradient( colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], startPoint: .top, endPoint: .bottom ) ) .interpolationMethod(.catmullRom) } case .byCategory: ForEach(categoryData) { item in AreaMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .interpolationMethod(.catmullRom) .opacity(0.5) } ForEach(stackedCategoryData) { item in LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .symbolSize(18) } } if showGoalLines { ForEach(goals) { goal in RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue)) .foregroundStyle(Color.appSecondary.opacity(0.4)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4])) } } } private var chartCategoryNames: [String] { let names = Array(Set(categoryData.map { $0.categoryName })).sorted() return names } private var chartCategoryColors: [Color] { chartCategoryNames.map { name in if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex { return Color(hex: hex) ?? .gray } return .gray } } } // MARK: - Contributions Chart struct ContributionsChartView: View { let data: [(date: Date, amount: Decimal)] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Contributions") .font(.headline) if data.isEmpty { Text("No contributions yet.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in BarMark( x: .value("Month", item.date), y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue) ) .foregroundStyle(Color.appSecondary) .cornerRadius(6) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .frame(height: 260) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } // MARK: - Rolling 12-Month Return struct RollingReturnChartView: View { let data: [(date: Date, value: Double)] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Rolling 12-Month Return") .font(.headline) if data.isEmpty { Text("Not enough data for rolling returns.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in LineMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .interpolationMethod(.catmullRom) PointMark( x: .value("Month", item.date), y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) .symbolSize(24) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .frame(height: 260) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } // MARK: - Risk vs Return struct RiskReturnChartView: View { let data: [(category: String, cagr: Double, volatility: Double, color: String)] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Risk vs Return") .font(.headline) if data.isEmpty { Text("Not enough data to compare categories.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.category) { item in PointMark( x: .value("Volatility", item.volatility), y: .value("CAGR", item.cagr) ) .foregroundStyle(Color(hex: item.color) ?? .gray) .symbolSize(60) .annotation(position: .top) { Text(item.category) .font(.caption2) .foregroundColor(.secondary) } } } .chartXAxis { AxisMarks(position: .bottom) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(String(format: "%.1f%%", doubleValue)) .font(.caption) } } } } .frame(height: 260) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } // MARK: - Net Performance vs Contributions struct CashflowStackedChartView: View { let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Net Performance vs Contributions") .font(.headline) if data.isEmpty { Text("Not enough data to compare cashflow.") .foregroundColor(.secondary) .frame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue let stackedEnd = contributionValue + netValue BarMark( x: .value("Month", item.date), yStart: .value("Start", 0), yEnd: .value("Contributions", contributionValue) ) .foregroundStyle(Color.appSecondary.opacity(0.8)) BarMark( x: .value("Month", item.date), yStart: .value("Start", contributionValue), yEnd: .value("Net", stackedEnd) ) .foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let doubleValue = value.as(Double.self) { Text(Decimal(doubleValue).shortCurrencyString) .font(.caption) } } } } .frame(height: 260) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } #Preview { ChartsContainerView(iapService: IAPService()) .environmentObject(AccountStore(iapService: IAPService())) }