Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/EvolutionChart.swift
T
alexandrev-tibco 41e2a22a64 Swipe fiable en card del Home y Charts: simultaneousGesture sobre la gráfica (build 63)
El swipe entre páginas del Home (build 62) y entre charts (build 60) no
funcionaba encima de la gráfica: su DragGesture de scrub (minimumDistance 0)
consumía el gesto. Cambiado a .simultaneousGesture — ambos se reconocen; la
dominancia horizontal en onEnded evita romper el scroll vertical. Ahora el swipe
funciona en toda la card, también sobre el chart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-11 10:50:52 +02:00

445 lines
16 KiB
Swift

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()
}