import SwiftUI import Charts /// Swipeable pages for the Home evolution card. enum DashboardChartPage: Hashable { case evolution, allocation var title: String { switch self { case .evolution: return String(localized: "Portfolio Evolution") case .allocation: return String(localized: "Asset Allocation") } } } struct EvolutionChartCard: View { let data: [(date: Date, value: Decimal)] let categoryData: [CategoryEvolutionPoint] let goals: [Goal] /// Current allocation per category (name, value, colorHex) for the swipeable /// Allocation page. Empty → the Allocation page is skipped. var allocation: [(category: String, value: Decimal, color: String)] = [] @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @State private var showGoalLines = true @State private var chartWidth: CGFloat = 300 // Feedback #4: swipe the Home card between a curated set of glanceable charts. @State private var page: DashboardChartPage = .evolution @State private var pageInsertionEdge: Edge = .trailing @State private var allocationSelection: String? /// Pages available given the data on hand. "By Category" already lives as a /// toggle inside the Evolution page, so it isn't a separate swipe page. private var pages: [DashboardChartPage] { var p: [DashboardChartPage] = [.evolution] if !allocation.isEmpty { p.append(.allocation) } return p } 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 }() /// Calculates the optimal month stride so labels never overlap, /// using the actual rendered width of the chart instead of just data count. private func xAxisMonthStride(for width: CGFloat) -> Int { // ~50pt for Y-axis, ~44pt per "Jan 24" label let usableWidth = max(width - 50, 80) let maxLabels = max(2, Int(usableWidth / 44)) let rawStride = max(1, Int(ceil(Double(data.count) / Double(maxLabels)))) switch rawStride { case ...1: return 1 case ...2: return 2 case ...3: return 3 case ...4: return 4 case ...6: return 6 default: return 12 } } var body: some View { VStack(alignment: .leading, spacing: 12) { headerView pageContent .id(page) .transition(.asymmetric( insertion: .move(edge: pageInsertionEdge).combined(with: .opacity), removal: .opacity )) if pages.count > 1 { pageIndicator } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) .contentShape(Rectangle()) // simultaneousGesture so the swipe is recognized even over the chart, // whose own scrub DragGesture would otherwise consume it. Horizontal // dominance is enforced in onEnded so vertical scroll still works; a // single page is a harmless no-op (bounds prevent movement). .simultaneousGesture(pageSwipeGesture) } @ViewBuilder private var pageContent: some View { switch page { case .evolution: modePicker chartSection case .allocation: SemicircleAllocation( data: allocation, total: allocation.reduce(Decimal.zero) { $0 + $1.value }, selectedSlice: $allocationSelection ) .frame(height: 150) .padding(.vertical, 8) allocationLegend } } private var headerView: some View { HStack { Text(page.title) .font(.headline) Spacer() if page == .evolution { 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) } } } } } // MARK: - Swipe between pages private var pageSwipeGesture: some Gesture { DragGesture(minimumDistance: 30) .onEnded { value in guard abs(value.translation.width) > abs(value.translation.height) * 1.5, let idx = pages.firstIndex(of: page) else { return } if value.translation.width < 0, idx < pages.count - 1 { pageInsertionEdge = .trailing withAnimation(.snappy) { page = pages[idx + 1] } } else if value.translation.width > 0, idx > 0 { pageInsertionEdge = .leading withAnimation(.snappy) { page = pages[idx - 1] } } } } private var pageIndicator: some View { let current = pages.firstIndex(of: page) ?? 0 return HStack(spacing: 5) { ForEach(pages.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) } } .frame(maxWidth: .infinity) .animation(.snappy, value: current) } private var allocationLegend: some View { VStack(spacing: 6) { ForEach(allocation.prefix(4), id: \.category) { item in let total = allocation.reduce(Decimal.zero) { $0 + $1.value } let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0 HStack(spacing: 8) { Circle().fill(Color(hex: item.color) ?? .gray).frame(width: 9, height: 9) Text(item.category).font(.caption) Spacer() Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium)) Text(item.value.compactCurrencyString).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: xAxisMonthStride(for: chartWidth))) { 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 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) .background( GeometryReader { geo in Color.clear .onAppear { chartWidth = geo.size.width } .onChange(of: geo.size.width) { _, w in chartWidth = w } } ) // 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() }