Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento

Charts iPad (rediseño):
- Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max
  Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre
  los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a
  calculateMetrics producía KPIs absurdos)
- Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección:
  Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad
- Selector de periodo como Picker segmentado nativo en la cabecera del chart
  (fuera del sidebar); título + descripción del chart visibles
- Eliminado sheet de paywall duplicado del layout iPad

Zoom (todas las series temporales):
- Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el
  único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom
- Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility

Focus Mode eliminado (todo siempre disponible):
- Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage
- Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList
- Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in
- PeriodReturnsCard siempre visible

Rendimiento y bugs (de la auditoría):
- El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown
- calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación
  lineal sobreestimaba con historiales cortos)
- Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación
  real de las asignaciones simuladas del usuario
- UITest de captura de Charts con manejo del alert de notificaciones

12 strings nuevas localizadas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-07-03 10:17:10 +02:00
parent d0a51d0162
commit db844d5e80
19 changed files with 557 additions and 230 deletions
@@ -5,7 +5,6 @@ struct ChartsContainerView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: ChartsViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -26,7 +25,6 @@ struct ChartsContainerView: View {
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
}
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -47,93 +45,56 @@ struct ChartsContainerView: View {
}
}
// MARK: - Focus Mode Button
private var focusModeButton: some View {
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
Text(calmModeEnabled ? "Focus" : "Full")
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.cornerRadius(14)
}
.buttonStyle(.plain)
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
HStack(spacing: 0) {
// Left sidebar: chart type list + filters
// Left sidebar: grouped chart-type tiles + contextual filters
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Chart Type")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
ForEach(Self.chartGroups, id: \.titleKey) { group in
let types = group.types.filter { showForecast || $0 != .prediction }
if !types.isEmpty {
Text(String(localized: String.LocalizationValue(group.titleKey)))
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 6)
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
.filter { showForecast || $0 != .prediction }
ForEach(types) { chartType in
iPadChartTypeRow(chartType)
}
Divider().padding(.vertical, 8)
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
.font(.system(size: 14))
.foregroundColor(calmModeEnabled ? .white : .primary)
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) {
ForEach(types) { chartType in
iPadChartTypeTile(chartType)
}
}
Text("Focus Mode")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.font(.system(size: 16))
.padding(.horizontal, 12)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
}
.buttonStyle(.plain)
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
.padding(.horizontal, 16)
.padding(.bottom, 16)
} else {
Spacer().frame(height: 16)
}
}
}
.frame(width: 220)
.frame(width: 248)
.background(Color(.systemBackground))
Divider()
// Right panel: full-width chart
// Right panel: KPI header + period control + chart
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 16) {
kpiHeader
chartToolbar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
@@ -150,7 +111,79 @@ struct ChartsContainerView: View {
}
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - Chart groups (sidebar sections)
private static let chartGroups: [(titleKey: String, types: [ChartsViewModel.ChartType])] = [
("chart_group_overview", [.evolution, .allocation, .performance, .contributions]),
("chart_group_analyze", [.comparison, .periodComparison, .yearOverYear, .rollingReturn]),
("chart_group_risk", [.drawdown, .volatility, .riskReturn, .cashflow]),
("chart_group_forecast", [.prediction, .simulator])
]
// MARK: - KPI Header (iPad)
/// Portfolio-level metrics for the active time range, always visible above the chart.
private var kpiHeader: some View {
let m = viewModel.portfolioMetrics
return HStack(spacing: 10) {
kpiCard(label: String(localized: "kpi_total_value"), value: m.formattedTotalValue, color: .primary)
kpiCard(label: String(localized: "kpi_period_return"), value: m.formattedPercentageReturn,
color: m.percentageReturn >= 0 ? .positiveGreen : .negativeRed)
kpiCard(label: String(localized: "kpi_cagr"), value: m.formattedCAGR,
color: m.cagr >= 0 ? .positiveGreen : .negativeRed)
kpiCard(label: String(localized: "kpi_volatility"), value: m.formattedVolatility, color: .primary)
kpiCard(label: String(localized: "kpi_max_drawdown"), value: m.formattedMaxDrawdown, color: .negativeRed)
}
}
private func kpiCard(label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(value)
.font(.system(.title3, design: .rounded).weight(.semibold))
.foregroundColor(color)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.smallCornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
}
// MARK: - Chart toolbar (iPad): title + native segmented period picker
@ViewBuilder
private var chartToolbar: some View {
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedChartType.rawValue)
.font(.headline)
Text(viewModel.selectedChartType.description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
if !ranges.isEmpty && viewModel.selectedChartType != .performance {
Picker("Period", selection: $viewModel.selectedTimeRange) {
ForEach(ranges) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 360)
}
}
.padding(.horizontal, 4)
}
/// Concrete loss-framing upsell: the user has real data older than the free
@@ -202,38 +235,44 @@ struct ChartsContainerView: View {
}
}
// MARK: - iPad Chart Type Row
// MARK: - iPad Chart Type Tile
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
private func iPadChartTypeTile(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
return Button {
viewModel.selectChart(chartType)
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
VStack(spacing: 6) {
ZStack(alignment: .topTrailing) {
Image(systemName: chartType.icon)
.font(.system(size: 14))
.foregroundColor(isSelected ? .white : .primary)
.font(.system(size: 18))
.foregroundColor(isSelected ? .white : .appPrimary)
.frame(maxWidth: .infinity)
.frame(height: 34)
if isPremiumLocked {
Image(systemName: "lock.fill")
.font(.system(size: 9))
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
}
}
Text(chartType.rawValue)
.font(.subheadline)
.foregroundColor(isPremiumLocked ? .secondary : .primary)
Spacer()
if isPremiumLocked {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
}
.font(.caption2.weight(isSelected ? .semibold : .regular))
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(2)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.8)
.frame(height: 26, alignment: .top)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
.padding(.vertical, 8)
.padding(.horizontal, 4)
.frame(maxWidth: .infinity)
.background(isSelected ? Color.appPrimary : Color(.systemGray6))
.cornerRadius(10)
}
.buttonStyle(.plain)
.accessibilityLabel(chartType.rawValue)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
// MARK: - Shared
@@ -253,7 +292,7 @@ struct ChartsContainerView: View {
private var chartTypeSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in
ForEach(viewModel.availableChartTypes.filter { showForecast || $0 != .prediction }) { chartType in
ChartTypeButton(
chartType: chartType,
isSelected: viewModel.selectedChartType == chartType,
@@ -272,11 +311,13 @@ struct ChartsContainerView: View {
private var hasAnyFilter: Bool {
let chartType = viewModel.selectedChartType
let isPad = horizontalSizeClass == .regular
// These chart types manage their own controls internally, no external filter bar needed
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
return !viewModel.availableTimeRanges(for: chartType).isEmpty
// On iPad the period picker lives in the chart toolbar, not the filter bar.
return isPad ? false : !viewModel.availableTimeRanges(for: chartType).isEmpty
}
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
let hasTimeRange = !isPad && chartType != .allocation && chartType != .riskReturn
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
let hasBreakdown = chartType == .allocation || chartType == .performance
@@ -289,8 +330,10 @@ struct ChartsContainerView: View {
VStack(alignment: .leading, spacing: 10) {
let chartType = viewModel.selectedChartType
// Time Range (not for performance uses slider)
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
// Time Range (not for performance uses slider). On iPad the period
// picker lives in the chart toolbar, not the sidebar.
if horizontalSizeClass != .regular
&& chartType != .allocation && chartType != .riskReturn && chartType != .performance {
filterRow(icon: "calendar", label: "Period") {
timeRangeSelector
}
@@ -722,6 +765,7 @@ struct ChartTypeButton: View {
struct EvolutionChartView: View {
let data: [(date: Date, value: Decimal)]
@State private var zoom = ChartZoomModel()
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@@ -933,6 +977,7 @@ struct EvolutionChartView: View {
}
}
.frame(height: 300)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Performance: Use GPU rendering for smoother scrolling on older devices
.drawingGroup()
}
@@ -1025,6 +1070,7 @@ struct EvolutionChartView: View {
struct ContributionsChartView: View {
let data: [(date: Date, amount: Decimal)]
@State private var zoom = ChartZoomModel()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -1062,6 +1108,7 @@ struct ContributionsChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
@@ -1091,6 +1138,7 @@ struct ContributionsChartView: View {
struct RollingReturnChartView: View {
let data: [(date: Date, value: Double)]
@State private var zoom = ChartZoomModel()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -1135,6 +1183,7 @@ struct RollingReturnChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
@@ -1250,6 +1299,7 @@ struct RiskReturnChartView: View {
struct CashflowStackedChartView: View {
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
@State private var zoom = ChartZoomModel()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -1298,6 +1348,7 @@ struct CashflowStackedChartView: View {
}
}
.frame(height: 260)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}