300 lines
10 KiB
Swift
300 lines
10 KiB
Swift
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()
|
|
}
|