Charts: fix selección iPad/charts bloqueadas, filtro Rolling 12M, KPIs específicos y botón compartir

Bugs:
- Charts bloqueadas (intermitente) y tiles que no responden: el observer Combine
  registraba el estado como actualizado ANTES de llamar a updateChartData, cuyo
  guard de reentrada descartaba la llamada en silencio — reintentarlo era no-op
  permanente. Ahora el bookkeeping vive dentro de updateChartData, las llamadas
  reentrantes se colean en vez de descartarse, y selectChart computa en síncrono
  (pulsar un tile es determinista y re-pulsar actúa de retry).
- Paywall sheet: movida del Group que cambia con el size class a cada layout —
  en NavigationSplitView no llegaba a presentarse (tiles premium 'no hacían nada').
- Rolling 12M: el filtro de periodo no filtraba — el cálculo necesita histórico
  completo para el lookback, pero ahora la salida respeta selectedTimeRange.

KPIs:
- El header de iPad muestra KPIs específicos del chart activo (los mismos que su
  stats row) en vez de las 5 métricas de cartera fijas; las stats rows dentro de
  las cards se ocultan en regular width para no duplicar (YoY opta por quedarse
  al no tener equivalente en el header).

Compartir:
- Botón de compartir por chart (toolbar iPad + navbar iPhone): renderiza la
  gráfica en una card con branding (BrandMark, KPIs, tagline, QR al App Store
  con ct=chart_share) vía ImageRenderer y abre el share sheet con imagen + link.
- drawingGroup() se desactiva durante el export (ImageRenderer no rasteriza
  capas Metal — salían en blanco). Strings nuevas en 7 idiomas.

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 16:32:42 +02:00
parent a830a5f40c
commit 0e0aec6bb9
15 changed files with 476 additions and 54 deletions
@@ -2,6 +2,7 @@ import SwiftUI
import Charts
struct AllocationSimulatorView: View {
@Environment(\.chartImageExport) private var chartImageExport
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
@@ -194,7 +195,7 @@ struct AllocationSimulatorView: View {
}
}
.frame(height: 220)
.drawingGroup()
.chartDrawingGroup(disabledForExport: chartImageExport)
}
private var chartLegend: some View {
@@ -10,23 +10,31 @@ struct ChartStat {
// MARK: - Stats summary row (horizontal scroll of chips)
struct ChartStatsRow: View {
let stats: [ChartStat]
/// On regular width the charts container surfaces these stats in the KPI
/// header above the chart hide the in-card duplicate row. Charts without a
/// header equivalent (e.g. Year vs Year) opt back in.
var showsOnRegularWidth = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
VStack(alignment: .center, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
if horizontalSizeClass != .regular || showsOnRegularWidth {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
VStack(alignment: .center, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
@@ -22,9 +22,11 @@ struct ChartsContainerView: View {
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
if horizontalSizeClass != .regular {
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
}
}
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -111,6 +113,10 @@ struct ChartsContainerView: View {
}
}
.ignoresSafeArea(edges: .bottom)
// Attached here (not on the layout-switching Group): sheets presented from
// a view that gets replaced when the size class changes were unreliable in
// NavigationSplitView premium tiles looked like they "did nothing".
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - Chart groups (sidebar sections)
@@ -124,17 +130,137 @@ struct ChartsContainerView: View {
// MARK: - KPI Header (iPad)
/// Portfolio-level metrics for the active time range, always visible above the chart.
/// 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.
@ViewBuilder
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)
let stats = chartSpecificStats
if !stats.isEmpty {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
kpiCard(label: stats[i].label, value: stats[i].value, color: stats[i].color)
}
}
}
}
private var chartSpecificStats: [ChartStat] {
switch viewModel.selectedChartType {
case .evolution:
let data = viewModel.evolutionData
guard !data.isEmpty else { return [] }
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
let latest = values.last ?? 0
let first = values.first ?? 0
let changePct = first > 0 ? ((latest - first) / first) * 100 : 0
let m = viewModel.portfolioMetrics
return [
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "CAGR", value: m.formattedCAGR, color: m.cagr >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Max", value: Decimal(values.max() ?? 0).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Min", value: Decimal(values.min() ?? 0).compactCurrencyString, color: .negativeRed),
]
case .allocation:
let data = viewModel.allocationData
guard !data.isEmpty else { return [] }
let total = data.reduce(Decimal.zero) { $0 + $1.value }
let top = data[0]
let topPct = total > 0 ? NSDecimalNumber(decimal: top.value / total * 100).doubleValue : 0
return [
ChartStat(label: "Total", value: total.compactCurrencyString, color: .primary),
ChartStat(label: "Largest", value: top.category, color: Color(hex: top.color) ?? .appPrimary),
ChartStat(label: "Top share", value: String(format: "%.1f%%", topPct), color: .appPrimary),
ChartStat(label: "Positions", value: "\(data.count)", color: .secondary),
]
case .performance:
let data = viewModel.performanceData
guard !data.isEmpty else { return [] }
let avg = data.map { $0.cagr }.reduce(0, +) / Double(data.count)
let best = data.max(by: { $0.cagr < $1.cagr })
let worst = data.min(by: { $0.cagr < $1.cagr })
return [
ChartStat(label: "Best", value: best.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .positiveGreen),
ChartStat(label: "Worst", value: worst.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%+.1f%%", avg), color: .secondary),
]
case .contributions:
let data = viewModel.contributionsData
guard !data.isEmpty else { return [] }
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
let total = amounts.reduce(0, +)
return [
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
ChartStat(label: "Monthly avg", value: Decimal(total / Double(amounts.count)).compactCurrencyString, color: .secondary),
ChartStat(label: "Highest", value: Decimal(amounts.max() ?? 0).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Last", value: Decimal(amounts.last ?? 0).compactCurrencyString, color: .appPrimary),
]
case .rollingReturn:
let values = viewModel.rollingReturnData.map { $0.value }
guard !values.isEmpty else { return [] }
let latest = values.last ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best", value: String(format: "%.1f%%", values.max() ?? 0), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", values.min() ?? 0), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .riskReturn:
let data = viewModel.riskReturnData
guard !data.isEmpty else { return [] }
let bestReturn = data.max(by: { $0.cagr < $1.cagr })
let lowestRisk = data.min(by: { $0.volatility < $1.volatility })
return [
ChartStat(label: "Best return", value: bestReturn.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "", color: .positiveGreen),
ChartStat(label: "Lowest risk", value: lowestRisk.map { "\($0.category) \(String(format: "%.1f%%", $0.volatility))" } ?? "", color: .appPrimary),
ChartStat(label: "Categories", value: "\(data.count)", color: .secondary),
]
case .cashflow:
let data = viewModel.cashflowData
guard !data.isEmpty else { return [] }
let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions }
let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance }
let net = totalContrib + totalPerf
return [
ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed),
]
case .drawdown:
let values = viewModel.drawdownData.map { $0.drawdown }
guard !values.isEmpty else { return [] }
let current = values.last ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Current", value: String(format: "%.1f%%", current), color: current >= -1 ? .positiveGreen : .negativeRed),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .volatility:
let values = viewModel.volatilityData.map { $0.volatility }
guard !values.isEmpty else { return [] }
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Current", value: String(format: "%.1f%%", values.last ?? 0), color: .appPrimary),
ChartStat(label: "Max", value: String(format: "%.1f%%", values.max() ?? 0), color: .negativeRed),
ChartStat(label: "Min", value: String(format: "%.1f%%", values.min() ?? 0), color: .positiveGreen),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
case .prediction:
guard let lastPred = viewModel.predictionData.last else { return [] }
let current = viewModel.evolutionData.last?.value ?? 0
return [
ChartStat(label: "Current", value: current.compactCurrencyString, color: .appPrimary),
ChartStat(label: "Base (\(viewModel.predictionData.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
]
case .yearOverYear, .comparison, .simulator, .periodComparison:
// These charts carry their own comparison summaries inside the card.
return []
}
}
@@ -182,10 +308,33 @@ struct ChartsContainerView: View {
.pickerStyle(.segmented)
.frame(maxWidth: 360)
}
shareButton
}
.padding(.horizontal, 4)
}
private var shareButton: some View {
Button {
shareCurrentChart()
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(!viewModel.hasData || viewModel.isLoading)
.accessibilityLabel(String(localized: "chart_share_button"))
}
private func shareCurrentChart() {
ChartShareService.shared.share(
title: viewModel.selectedChartType.rawValue,
subtitle: viewModel.selectedChartType.description,
stats: chartSpecificStats
) {
chartContent
.environmentObject(accountStore)
.frame(width: 672)
}
}
/// Concrete loss-framing upsell: the user has real data older than the free
/// 12-month window; tell them exactly how much is locked instead of hiding it.
private var lockedHistoryTeaser: some View {
@@ -233,6 +382,7 @@ struct ChartsContainerView: View {
.padding()
}
}
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPad Chart Type Tile
@@ -768,6 +918,7 @@ struct EvolutionChartView: View {
@State private var zoom = ChartZoomModel()
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@Environment(\.chartImageExport) private var chartImageExport
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@@ -978,8 +1129,8 @@ struct EvolutionChartView: View {
}
.frame(height: 300)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Performance: Use GPU rendering for smoother scrolling on older devices
.drawingGroup()
// Performance: GPU rendering for smoother scrolling on older devices
.chartDrawingGroup(disabledForExport: chartImageExport)
}
@ChartContentBuilder
@@ -1434,6 +1585,7 @@ struct AllocationEvolutionDataPoint: Identifiable {
struct AllocationEvolutionChart: View {
let data: [(date: Date, category: String, percentage: Double, color: String)]
@Environment(\.chartImageExport) private var chartImageExport
private var identifiableData: [AllocationEvolutionDataPoint] {
data.enumerated().map { index, item in
@@ -1516,7 +1668,7 @@ struct AllocationEvolutionChart: View {
}
}
.frame(height: 260)
.drawingGroup()
.chartDrawingGroup(disabledForExport: chartImageExport)
}
}
@@ -2,6 +2,7 @@ import SwiftUI
import Charts
struct ComparisonChartView: View {
@Environment(\.chartImageExport) private var chartImageExport
@State private var selectedDate: Date?
private static let selectionDateFormatter: DateFormatter = {
@@ -187,7 +188,7 @@ struct ComparisonChartView: View {
}
}
.frame(height: 260)
.drawingGroup()
.chartDrawingGroup(disabledForExport: chartImageExport)
legend
}
@@ -2,6 +2,7 @@ import SwiftUI
import Charts
struct PeriodComparisonChartView: View {
@Environment(\.chartImageExport) private var chartImageExport
@State private var selectedMonth: Int?
/// Period charts are short by design (a handful of months) labels always help.
@@ -297,7 +298,7 @@ struct PeriodComparisonChartView: View {
}
}
.frame(height: 260)
.drawingGroup()
.chartDrawingGroup(disabledForExport: chartImageExport)
.onChange(of: seriesIds) { _, _ in }
}
@@ -257,7 +257,7 @@ struct YearOverYearChartView: View {
color: .positiveGreen),
ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff),
color: .negativeRed),
])
], showsOnRegularWidth: true)
}
}