Charts iPhone workable: chip bar, ventana+brush, header vivo, drill-down, overlay #29 #30

Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu
como navegación primaria), sin bubbles permanentes en compact (solo endpoint),
Performance a barras horizontales con >6 categorías, línea con gradiente y
estética de widget, pinch anclado al centro + doble-tap reset + haptics.

Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush;
minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango
con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con
haptic); drill-down por tap en Allocation/Performance → Evolution filtrada
(premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas
(premium); insight contextual del rango visible (premium). L10n ×7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
This commit is contained in:
alexandrev-tibco
2026-08-06 18:03:50 +02:00
parent 525911f67a
commit d38c6943e1
14 changed files with 1031 additions and 222 deletions
@@ -595,7 +595,8 @@ struct ChartsContainerView: View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 16) {
chartTypeChipBar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
@@ -603,6 +604,9 @@ struct ChartsContainerView: View {
}
}
iPhonePeriodControl
if hasActiveFilters {
activeFilterChip
}
chartContent
// Swipe left/right to move to the adjacent chart type.
// simultaneousGesture so charts with their own scrub drag
@@ -613,7 +617,6 @@ struct ChartsContainerView: View {
insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity),
removal: .opacity
))
chartPageIndicator
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
@@ -624,6 +627,92 @@ struct ChartsContainerView: View {
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone chart type chip bar
//
// Always-visible horizontal switcher (Health-app pattern) replaces the
// hidden title menu as primary navigation and the broken page-dot row.
// Swipe still works and stays in sync via the auto-scroll.
private var chartTypeChipBar: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(orderedChartTypes) { chartType in
chartTypeChip(chartType)
.id(chartType)
}
}
.padding(.horizontal, 2)
.padding(.vertical, 2)
}
.onChange(of: viewModel.selectedChartType) { _, newValue in
withAnimation(.snappy) { proxy.scrollTo(newValue, anchor: .center) }
}
.onAppear {
proxy.scrollTo(viewModel.selectedChartType, anchor: .center)
}
}
}
private func chartTypeChip(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let locked = chartType.isPremium && !viewModel.isPremium
return Button {
guard !isSelected else { return }
ChartHaptics.tick()
withAnimation(.snappy) { viewModel.selectChart(chartType) }
} label: {
HStack(spacing: 5) {
Image(systemName: locked ? "lock.fill" : chartType.icon)
.font(.system(size: 11, weight: .semibold))
Text(chartType.rawValue)
.font(.footnote.weight(isSelected ? .semibold : .regular))
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
.background(
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
)
.overlay(
Capsule().stroke(isSelected ? Color.clear : Color.gray.opacity(0.25), lineWidth: 0.8)
)
}
.buttonStyle(.plain)
}
/// Visible affordance for an active drill-down/filter tap to clear.
private var activeFilterChip: some View {
let name = viewModel.selectedCategory?.name
?? viewModel.availableSources(for: viewModel.selectedChartType)
.first(where: { viewModel.selectedSourceIds.contains($0.id) })?.name
?? ""
return HStack {
Button {
withAnimation(.snappy) {
viewModel.selectedCategory = nil
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
viewModel.selectedBreakdown = .category
}
ChartHaptics.bump()
} label: {
HStack(spacing: 6) {
Text(String(format: String(localized: "chart_filtered_prefix"), name))
.font(.footnote.weight(.medium))
Image(systemName: "xmark.circle.fill")
.font(.footnote)
}
.foregroundColor(.appPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
}
.buttonStyle(.plain)
Spacer()
}
}
// MARK: - iPhone swipe between charts
/// Chart types in the same grouped order as the title switcher menu.
@@ -650,19 +739,6 @@ struct ChartsContainerView: View {
}
}
private var chartPageIndicator: some View {
let ordered = orderedChartTypes
let current = ordered.firstIndex(of: viewModel.selectedChartType) ?? 0
return HStack(spacing: 5) {
ForEach(ordered.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)
}
}
.animation(.snappy, value: current)
}
/// Stocks-style segmented period control above the chart (the chart switcher
/// lives in the title menu, filters in the toolbar).
@ViewBuilder
@@ -692,6 +768,34 @@ struct ChartsContainerView: View {
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
/// Drill-down: tapping a category/source in Allocation or Performance jumps
/// to Evolution filtered to it (premium). The active-filter chip is the way
/// back.
private func drillDown(to name: String) {
guard viewModel.isPremium else {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
return
}
ChartHaptics.bump()
withAnimation(.snappy) {
if viewModel.selectedBreakdown == .source {
if let source = viewModel.availableSources(for: viewModel.selectedChartType)
.first(where: { $0.name == name }) {
viewModel.selectedCategory = nil
viewModel.selectedSource = nil
viewModel.selectedSourceIds = [source.id]
}
} else if let category = viewModel.availableCategories(for: viewModel.selectedChartType)
.first(where: { $0.name == name }) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
viewModel.selectedCategory = category
}
viewModel.selectChart(.evolution)
}
}
private func periodLabel(_ months: Int) -> String {
if months < 12 {
return "\(months)M"
@@ -719,14 +823,22 @@ struct ChartsContainerView: View {
EvolutionChartView(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals
goals: goalsViewModel.goals,
contributions: viewModel.contributionsData,
isPremium: viewModel.isPremium,
onExpandRange: { viewModel.selectedTimeRange = .all },
onPremiumNeeded: {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
}
)
case .allocation:
VStack(spacing: 20) {
AllocationPieChart(
data: viewModel.allocationData,
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown),
onDrillDown: { name in drillDown(to: name) }
)
if !viewModel.allocationEvolutionData.isEmpty {
@@ -736,7 +848,8 @@ struct ChartsContainerView: View {
case .performance:
PerformanceBarChart(
data: viewModel.performanceData,
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category",
onDrillDown: { name in drillDown(to: name) }
)
case .contributions:
ContributionsChartView(data: viewModel.contributionsData)
@@ -875,6 +988,10 @@ struct EvolutionChartView: View {
@State private var zoom = ChartZoomModel()
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
var contributions: [(date: Date, amount: Decimal)] = []
var isPremium: Bool = true
var onExpandRange: (() -> Void)? = nil
var onPremiumNeeded: (() -> Void)? = nil
@Environment(\.chartImageExport) private var chartImageExport
@Environment(\.horizontalSizeClass) private var labelsSizeClass
@@ -883,6 +1000,7 @@ struct EvolutionChartView: View {
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = false
@State private var showContributions = false
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
@@ -955,7 +1073,23 @@ struct EvolutionChartView: View {
VStack(alignment: .leading, spacing: 12) {
headerView
modePicker
if chartMode == .total && !contributions.isEmpty {
HStack {
contributionsToggle
Spacer()
}
}
chartSection
if chartMode == .total, isPremium, let insight = rangeInsight {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.caption2)
.foregroundColor(.appWarning)
Text(insight)
.font(.caption)
.foregroundColor(.secondary)
}
}
if !data.isEmpty && chartMode == .total {
ChartStatsRow(stats: evolutionStats)
}
@@ -966,6 +1100,69 @@ struct EvolutionChartView: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
/// One contextual takeaway recomputed for the visible window (premium).
private var rangeInsight: String? {
let series = visibleData
guard series.count >= 3 else { return nil }
var bestDate: Date?
var bestPct = -Double.infinity
for i in 1..<series.count {
let prev = NSDecimalNumber(decimal: series[i - 1].value).doubleValue
let cur = NSDecimalNumber(decimal: series[i].value).doubleValue
guard prev > 0 else { continue }
let pct = (cur - prev) / prev * 100
if pct > bestPct {
bestPct = pct
bestDate = series[i].date
}
}
guard let bestDate, bestPct.isFinite else { return nil }
return String(
format: String(localized: "chart_insight_best_month"),
Self.headerRangeFormatter.string(from: bestDate),
String(format: "%+.1f%%", bestPct)
)
}
/// Premium overlay: cumulative contributed capital vs market value shows
/// at a glance what is *your money* and what is growth.
private var contributionsToggle: some View {
Button {
if isPremium {
withAnimation(.snappy) { showContributions.toggle() }
ChartHaptics.tick()
} else {
onPremiumNeeded?()
}
} label: {
HStack(spacing: 4) {
if !isPremium {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
}
Text(String(localized: "chart_overlay_contributions"))
.font(.caption2.weight(.medium))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.foregroundColor(showContributions ? .appSecondary : .secondary)
.background(
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
)
}
.buttonStyle(.plain)
}
private var cumulativeContributions: [(date: Date, value: Double)] {
var running = 0.0
return contributions
.sorted { $0.date < $1.date }
.map { item in
running += NSDecimalNumber(decimal: item.amount).doubleValue
return (item.date, running)
}
}
private var evolutionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
@@ -983,23 +1180,65 @@ struct EvolutionChartView: View {
]
}
// MARK: Live header
//
// The numbers live HERE, not on the chart: while scrubbing it shows the
// exact point (value, date, delta vs window start); at rest it summarizes
// the visible window. This is what lets the chart itself stay clean.
/// Data restricted to the currently visible window (zoom/brush).
private var visibleData: [(date: Date, value: Decimal)] {
guard let window = zoom.visibleWindow(dates: data.map(\.date)) else { return data }
let filtered = data.filter { window.contains($0.date) }
return filtered.isEmpty ? data : filtered
}
private static let headerRangeFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
return f
}()
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
.font(.headline)
let series = visibleData
let current = (chartMode == .total ? selectedDataPoint : nil)
?? series.last.map { (date: $0.date, value: $0.value) }
let firstValue = series.first.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
let currentValue = current.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
let deltaPct = firstValue > 0 ? (currentValue - firstValue) / firstValue * 100 : 0
let subtitle: String = {
if let selected = selectedDataPoint, chartMode == .total {
return selected.date.monthYearString
}
guard let first = series.first?.date, let last = series.last?.date else { return "" }
return "\(Self.headerRangeFormatter.string(from: first)) \(Self.headerRangeFormatter.string(from: last))"
}()
return HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text((current?.value ?? 0).compactCurrencyString)
.font(.system(.title2, design: .rounded).weight(.bold))
.contentTransition(.numericText())
if series.count > 1 {
Text(String(format: "%+.1f%%", deltaPct))
.font(.caption.weight(.bold))
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
)
}
}
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing, spacing: 2) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
showGoalLines.toggle()
} label: {
@@ -1009,6 +1248,7 @@ struct EvolutionChartView: View {
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
}
.animation(.snappy, value: selectedDataPoint?.date)
}
private var modePicker: some View {
@@ -1077,6 +1317,10 @@ struct EvolutionChartView: View {
if let closest = data.min(by: {
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
}) {
// Haptic tick when the scrub snaps to a new point.
if selectedDataPoint?.date != closest.date {
ChartHaptics.tick()
}
selectedDataPoint = closest
}
}
@@ -1088,7 +1332,12 @@ struct EvolutionChartView: View {
}
}
.frame(height: 300)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.value).doubleValue },
zoom: $zoom,
onExpandRange: onExpandRange
)
// Performance: GPU rendering for smoother scrolling on older devices
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@@ -1097,24 +1346,34 @@ struct EvolutionChartView: View {
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
// Widget-parity styling: gradient stroke, soft area fill, and NO
// permanent dots/bubbles on iPhone the endpoint dot marks "now"
// and exact values live in the live header while scrubbing.
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
series: .value("Series", "total")
)
.foregroundStyle(Color.appPrimary)
.foregroundStyle(
LinearGradient(colors: [.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing)
)
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(30)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
if !labelsCompact {
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(30)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
}
@@ -1131,6 +1390,28 @@ struct EvolutionChartView: View {
)
.interpolationMethod(.catmullRom)
}
if labelsCompact, let last = data.last {
PointMark(
x: .value("Date", last.date),
y: .value("Value", NSDecimalNumber(decimal: last.value).doubleValue)
)
.foregroundStyle(Color.cyan)
.symbolSize(55)
}
if showContributions {
ForEach(cumulativeContributions, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Contributed", item.value),
series: .value("Series", "contributions")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 1.6, dash: [5, 4]))
.interpolationMethod(.monotone)
}
}
case .byCategory:
ForEach(categoryData) { item in
AreaMark(
@@ -1241,7 +1522,11 @@ struct ContributionsChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.amount).doubleValue },
zoom: $zoom
)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
@@ -1348,7 +1633,11 @@ struct RollingReturnChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map(\.value),
zoom: $zoom
)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
@@ -1513,7 +1802,11 @@ struct CashflowStackedChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.contributions + $0.netPerformance).doubleValue },
zoom: $zoom
)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}