Swipe entre mini-charts en la card del Home (build 62)

El feedback #4 original apuntaba a la card 'Portfolio Evolution' del HOME, no
solo a la pestaña Charts (donde ya se hizo en build 60). Ahora la card del Home
se desliza entre páginas:
- Evolution (con su toggle Total/By Category intacto)
- Allocation (semicírculo + leyenda con %), datos de viewModel.categoryMetrics
Puntos indicadores, transición direccional, título dinámico. 'By Category' no
es página aparte porque ya es un toggle dentro de Evolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
alexandrev-tibco
2026-07-10 22:13:00 +02:00
parent 7c6a29c821
commit d8bd625327
3 changed files with 130 additions and 28 deletions
@@ -1,15 +1,42 @@
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"
@@ -45,8 +72,14 @@ struct EvolutionChartCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
modePicker
chartSection
pageContent
.id(page)
.transition(.asymmetric(
insertion: .move(edge: pageInsertionEdge).combined(with: .opacity),
removal: .opacity
))
.gesture(pageSwipeGesture)
if pages.count > 1 { pageIndicator }
}
.padding()
.background(Color(.systemBackground))
@@ -54,29 +87,95 @@ struct EvolutionChartCard: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
@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("Portfolio Evolution")
Text(page.title)
.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)
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)
}
}
}
}