Fix causa raíz de taps muertos en Charts iPad: AppBackground interceptaba toques (build 50)

El círculo decorativo de AppBackground (70% del ancho del panel, offset -35%)
se desborda de su panel por diseño y en layouts side-by-side queda flotando
SOBRE el sidebar de Charts. Las shapes de SwiftUI participan en hit-testing por
defecto y el panel derecho va después en el HStack → cada tap en la zona del
sidebar cubierta por el círculo moría en silencio. El patrón lo delató: fallaban
Overview + Analyze (arriba, bajo el círculo) y funcionaban Risk + Forecast
(abajo). Dependía de la geometría (orientación/tamaño), por eso no reproducía
en el simulador en portrait.

- AppBackground: .allowsHitTesting(false) — un fondo decorativo jamás debe
  interceptar toques (fix global: aplica también a Sources/Journal en iPad)
- Panel de detalle de Charts: .clipped() para que la decoración tampoco PINTE
  sobre el sidebar
- Selección premium nunca se bloquea: los charts premium se seleccionan y
  muestran teaser de desbloqueo en el área del chart (chart_locked_* ×7 idiomas);
  el paywall se presenta desde el botón (contexto fiable)
- UITests: testChartSidebarSelection ahora corre en landscape (geometría que
  reproducía el bug) + testChartSelectionWithoutPremium nuevo; --no-premium
  en ScreenshotMode para testear la experiencia free

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-08 23:30:30 +02:00
parent 7b8dec196d
commit ff67ea1e71
13 changed files with 147 additions and 22 deletions
@@ -77,7 +77,8 @@ struct ChartsContainerView: View {
Divider()
// Right panel: KPI header + period control + chart
// Right panel: KPI header + period control + chart.
// Clipped so the decorative background can't paint over the sidebar.
ZStack {
AppBackground()
ScrollView {
@@ -100,6 +101,7 @@ struct ChartsContainerView: View {
.padding()
}
}
.clipped()
}
.ignoresSafeArea(edges: .bottom)
// Attached here (not on the layout-switching Group): sheets presented from
@@ -257,9 +259,13 @@ struct ChartsContainerView: View {
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
/// that make sense for it (the same ones its in-card stats row shows on
/// iPhone) instead of the always-identical portfolio aggregates.
private var selectedChartLocked: Bool {
viewModel.selectedChartType.isPremium && !viewModel.isPremium
}
@ViewBuilder
private var kpiHeader: some View {
let stats = chartSpecificStats
let stats = selectedChartLocked ? [] : chartSpecificStats
if !stats.isEmpty {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
@@ -464,7 +470,7 @@ struct ChartsContainerView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(!viewModel.hasData || viewModel.isLoading)
.disabled(!viewModel.hasData || viewModel.isLoading || selectedChartLocked)
.accessibilityLabel(String(localized: "chart_share_button"))
}
@@ -575,7 +581,9 @@ struct ChartsContainerView: View {
@ViewBuilder
private var chartContent: some View {
if viewModel.isLoading {
if viewModel.selectedChartType.isPremium && !viewModel.isPremium {
premiumLockedView
} else if viewModel.isLoading {
ProgressView()
.frame(height: 300)
} else if !viewModel.hasData {
@@ -634,6 +642,53 @@ struct ChartsContainerView: View {
}
}
/// Shown in place of a premium chart when the user isn't premium. Selecting
/// the chart always works; unlocking is an explicit button tap (a reliable
/// context for presenting the paywall sheet).
private var premiumLockedView: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 88, height: 88)
Image(systemName: viewModel.selectedChartType.icon)
.font(.system(size: 34))
.foregroundColor(.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 26))
.foregroundColor(.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 32, y: 30)
}
Text(String(format: String(localized: "chart_locked_title"), viewModel.selectedChartType.rawValue))
.font(.headline)
Text(viewModel.selectedChartType.description)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
viewModel.showingPaywall = true
} label: {
Text(String(localized: "chart_locked_cta"))
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(24)
}
}
.padding(32)
.frame(maxWidth: .infinity, minHeight: 320)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "chart.bar.xaxis")