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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user