import SwiftUI import Charts struct EvolutionChartCard: 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 = true enum ChartMode: String, CaseIterable, Identifiable { case total = "Total" case byCategory = "By Category" var id: String { rawValue } } 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() Button { showGoalLines.toggle() } label: { Image(systemName: showGoalLines ? "target" : "slash.circle") .foregroundColor(.secondary) } .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") if let selected = selectedDataPoint, chartMode == .total { VStack(alignment: .trailing) { Text(selected.value.compactCurrencyString) .font(.subheadline.weight(.semibold)) Text(selected.date.monthYearString) .font(.caption) .foregroundColor(.secondary) } } } } 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 to display chart") .font(.subheadline) .foregroundColor(.secondary) .frame(height: 200) .frame(maxWidth: .infinity) } } private var chartView: some View { Chart { chartMarks } .chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 3)) { 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 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: 200) // Performance: Use GPU rendering for smoother scrolling .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(26) 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(stackedCategoryData) { item in AreaMark( x: .value("Date", item.date), yStart: .value("Start", NSDecimalNumber(decimal: item.start).doubleValue), yEnd: .value("End", NSDecimalNumber(decimal: item.end).doubleValue) ) .foregroundStyle(by: .value("Category", item.categoryName)) .interpolationMethod(.catmullRom) } } if showGoalLines { ForEach(goals) { goal in RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue)) .foregroundStyle(Color.appSecondary.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4])) .annotation(position: .topTrailing) { Text(goal.name) .font(.caption2) .foregroundColor(.secondary) } } } if let selected = selectedDataPoint, chartMode == .total { RuleMark(x: .value("Selected", selected.date)) .foregroundStyle(Color.gray.opacity(0.3)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) PointMark( x: .value("Date", selected.date), y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue) ) .foregroundStyle(Color.appPrimary) .symbolSize(100) } } private var chartCategoryNames: [String] { let names = Array(Set(categoryData.map { $0.categoryName })).sorted() return names } private struct StackedCategoryPoint: Identifiable { let date: Date let categoryName: String let colorHex: String let start: Decimal let end: Decimal var id: String { "\(categoryName)-\(date.timeIntervalSince1970)" } } private var stackedCategoryData: [StackedCategoryPoint] { let grouped = Dictionary(grouping: categoryData) { $0.date } let dates = grouped.keys.sorted() let categories = chartCategoryNames var stacked: [StackedCategoryPoint] = [] for date in dates { let points = grouped[date] ?? [] var running: Decimal = 0 for category in categories { let value = points.first(where: { $0.categoryName == category })?.value ?? 0 let start = running let end = running + value running = end if let colorHex = points.first(where: { $0.categoryName == category })?.colorHex { stacked.append(StackedCategoryPoint( date: date, categoryName: category, colorHex: colorHex, start: start, end: end )) } } } return stacked } 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: - Mini Sparkline struct SparklineView: View { let data: [(date: Date, value: Decimal)] let color: Color var body: some View { if data.count >= 2 { Chart(data, id: \.date) { item in LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(color) .interpolationMethod(.catmullRom) } .chartXAxis(.hidden) .chartYAxis(.hidden) .chartLegend(.hidden) } else { Rectangle() .fill(Color.gray.opacity(0.1)) } } } #Preview { let sampleData: [(date: Date, value: Decimal)] = [ (Date().adding(months: -6), 10000), (Date().adding(months: -5), 10500), (Date().adding(months: -4), 10200), (Date().adding(months: -3), 11000), (Date().adding(months: -2), 11500), (Date().adding(months: -1), 11200), (Date(), 12000) ] return EvolutionChartCard(data: sampleData, categoryData: [], goals: []) .padding() }