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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user