Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)

Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el
swipe entre charts (el brush vivía dentro de chartContent, bajo el
simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de
periodo, fuera del área con gesto.

Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual
por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las
gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow,
comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries
(agregación mensual extraída de calculateEvolutionData como monthlySeries).
Presets limpian el rango custom; selección completa = sin filtro. El pinch
por-chart se mantiene como zoom visual.

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 20:33:58 +02:00
parent d941ecf27b
commit f1aeafccf6
5 changed files with 225 additions and 185 deletions
@@ -88,6 +88,11 @@ struct ChartsContainerView: View {
chartToolbar
if showRangeBrush && supportsGlobalRange {
globalRangeBrush
.transition(.opacity.combined(with: .move(edge: .top)))
}
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
@@ -503,6 +508,9 @@ struct ChartsContainerView: View {
.pickerStyle(.segmented)
.frame(maxWidth: 360)
}
if supportsGlobalRange {
rangeToggleButton
}
shareButton
}
.padding(.horizontal, 4)
@@ -740,22 +748,125 @@ struct ChartsContainerView: View {
}
/// Stocks-style segmented period control above the chart (the chart switcher
/// lives in the title menu, filters in the toolbar).
/// lives in the title menu, filters in the toolbar), plus the global range
/// brush toggle. The brush lives HERE outside chartContent so its drags
/// can't collide with the chart-swipe gesture, and its window filters the
/// data of EVERY chart (it replaces the preset as data range).
@ViewBuilder
private var iPhonePeriodControl: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
if viewModel.selectedChartType == .performance {
performancePeriodPicker
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
Text(range.rawValue).tag(range)
VStack(spacing: 10) {
HStack(spacing: 8) {
if viewModel.selectedChartType == .performance {
performancePeriodPicker
} else if !ranges.isEmpty {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
}
if supportsGlobalRange {
rangeToggleButton
}
}
.pickerStyle(.segmented)
if showRangeBrush && supportsGlobalRange {
globalRangeBrush
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
// MARK: - Global range brush (shared data window for all charts)
@State private var showRangeBrush = false
/// Charts whose data honors customRange (Performance/Simulator/Period vs
/// Period fetch their own windows).
private var supportsGlobalRange: Bool {
!viewModel.availableTimeRanges(for: viewModel.selectedChartType).isEmpty
&& viewModel.selectedChartType != .performance
&& viewModel.fullHistorySeries.count >= 2
}
private var rangeToggleButton: some View {
Button {
withAnimation(.snappy) { showRangeBrush.toggle() }
ChartHaptics.bump()
if showRangeBrush {
// Load the full history so any window can be selected.
if viewModel.selectedTimeRange != .all { viewModel.selectedTimeRange = .all }
} else {
viewModel.customRange = nil
}
} label: {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 14, weight: .medium))
.foregroundColor(showRangeBrush ? .white : .appPrimary)
.frame(width: 38, height: 30)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(showRangeBrush ? Color.appPrimary : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(showRangeBrush ? 0 : 0.3), lineWidth: 0.8)
)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "chart_range_button"))
}
@ViewBuilder
private var globalRangeBrush: some View {
let series = viewModel.fullHistorySeries
if series.count >= 2 {
ChartRangeBrush(
dates: series.map(\.date),
values: series.map { NSDecimalNumber(decimal: $0.value).doubleValue },
selection: rangeSelection
)
}
}
/// Maps the brush's month indices to the view model's date window.
/// Full selection = no filter (nil).
private var rangeSelection: Binding<ClosedRange<Int>> {
Binding(
get: {
let dates = viewModel.fullHistorySeries.map(\.date)
guard let last = dates.indices.last else { return 0...0 }
guard let range = viewModel.customRange else { return 0...last }
let lo = nearestIndex(in: dates, to: range.lowerBound)
let hi = nearestIndex(in: dates, to: range.upperBound)
return min(lo, hi)...max(lo, hi)
},
set: { sel in
let dates = viewModel.fullHistorySeries.map(\.date)
guard let last = dates.indices.last else { return }
if sel.lowerBound <= 0 && sel.upperBound >= last {
viewModel.customRange = nil
} else {
viewModel.setCustomRange(
fromMonth: dates[max(0, sel.lowerBound)],
toMonth: dates[min(last, sel.upperBound)]
)
}
}
)
}
private func nearestIndex(in dates: [Date], to date: Date) -> Int {
var best = 0
var bestDist = TimeInterval.greatestFiniteMagnitude
for (i, d) in dates.enumerated() {
let dist = abs(d.timeIntervalSince(date))
if dist < bestDist { best = i; bestDist = dist }
}
return best
}
// MARK: - Shared
private func syncState() {
@@ -826,7 +937,6 @@ struct ChartsContainerView: View {
goals: goalsViewModel.goals,
contributions: viewModel.contributionsData,
isPremium: viewModel.isPremium,
onExpandRange: { viewModel.selectedTimeRange = .all },
onPremiumNeeded: {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
@@ -990,7 +1100,6 @@ struct EvolutionChartView: View {
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
@@ -1332,12 +1441,7 @@ struct EvolutionChartView: View {
}
}
.frame(height: 300)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.value).doubleValue },
zoom: $zoom,
onExpandRange: onExpandRange
)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Performance: GPU rendering for smoother scrolling on older devices
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@@ -1522,11 +1626,7 @@ struct ContributionsChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.amount).doubleValue },
zoom: $zoom
)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
@@ -1633,11 +1733,7 @@ struct RollingReturnChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map(\.value),
zoom: $zoom
)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
@@ -1802,11 +1898,7 @@ struct CashflowStackedChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(
dates: data.map(\.date),
values: data.map { NSDecimalNumber(decimal: $0.contributions + $0.netPerformance).doubleValue },
zoom: $zoom
)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}