Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/YearOverYearChartView.swift
T
alexandrev-tibco 0e0aec6bb9 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
2026-07-08 16:32:42 +02:00

318 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
struct YearOverYearChartView: View {
@ObservedObject var viewModel: ChartsViewModel
@State private var selectedMonthIdx: Int?
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
private let lineColors: [Color] = [
.appPrimary, .appSecondary, .orange, .purple, .pink, .teal
]
private var allYears: [ChartsViewModel.YearSeries] { viewModel.yearOverYearData }
private var selectedSeries: [ChartsViewModel.YearSeries] {
allYears.filter { viewModel.yoySelectedYears.contains($0.year) }
.sorted { $0.year < $1.year }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: "chart_yoy_title"))
.font(.headline)
if allYears.isEmpty {
Text(String(localized: "chart_yoy_empty"))
.font(.subheadline)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 200)
} else {
yearSelector
if !selectedSeries.isEmpty {
// KPIs arriba del todo (#147)
yearEndStatsRow
if selectedSeries.count == 2 {
comparisonStatsHeader
}
if hasEstimate {
Text(String(localized: "chart_yoy_estimated_note"))
.font(.caption2)
.foregroundColor(.secondary)
}
// Detalle debajo (#147)
chartBody
legend
if selectedSeries.count == 2 {
comparisonTable
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Year Selector
private var yearSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(allYears) { ys in
let idx = allYears.firstIndex(where: { $0.id == ys.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let isSelected = viewModel.yoySelectedYears.contains(ys.year)
Button {
if isSelected {
viewModel.yoySelectedYears.remove(ys.year)
} else {
viewModel.yoySelectedYears.insert(ys.year)
}
} label: {
Text(String(ys.year))
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? color : .secondary)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - Chart
/// Two selected years × 12 months stays readable with permanent labels.
private var showAllLabels: Bool { selectedSeries.count <= 2 }
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
selectedSeries.compactMap { series in
let value = series.values[month]
guard !value.isNaN else { return nil }
let idx = allYears.firstIndex(where: { $0.id == series.id }) ?? 0
return (String(series.year), ChartLabels.percent(value), lineColors[idx % lineColors.count])
}
}
@ViewBuilder
private var chartBody: some View {
if #available(iOS 16.0, *) {
Chart {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
ForEach(0..<12, id: \.self) { monthIdx in
let value = yearSeries.values[monthIdx]
if !value.isNaN {
LineMark(
x: .value("Month", monthIdx),
y: .value("Return", value),
series: .value("Year", String(yearSeries.year))
)
.foregroundStyle(color)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Month", monthIdx),
y: .value("Return", value)
)
.foregroundStyle(color)
.symbolSize(showAllLabels ? 32 : 18)
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
if showAllLabels {
ChartValueBubble(text: ChartLabels.percent(value), color: color)
}
}
}
}
}
if let selectedMonthIdx, selectedMonthIdx >= 0, selectedMonthIdx < 12 {
RuleMark(x: .value("Selected", selectedMonthIdx))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
.annotation(position: .top, spacing: 4) {
ChartSelectionCard(
title: monthAbbreviations[selectedMonthIdx],
rows: selectionRows(for: selectedMonthIdx)
)
}
}
}
.chartXSelection(value: $selectedMonthIdx)
.chartXAxis {
AxisMarks(values: Array(0..<12)) { value in
AxisValueLabel {
if let idx = value.as(Int.self) {
Text(monthAbbreviations[idx])
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%+.1f%%", d))
.font(.caption2)
}
}
AxisGridLine()
}
}
.frame(height: 220)
} else {
Text("iOS 16+ required for chart")
.foregroundColor(.secondary)
.frame(height: 220)
}
}
// MARK: - Legend
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(lineColors[idx % lineColors.count])
.frame(width: 20, height: 3)
Text(String(yearSeries.year))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Year-end stats chips
private var yearEndStatsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let end = endValue(yearSeries)
VStack(alignment: .center, spacing: 3) {
Text(String(yearSeries.year))
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
.font(.subheadline.weight(.semibold))
.foregroundColor(end.value >= 0 ? color : .negativeRed)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
// MARK: - 2-year comparison: KPIs (top) and table (bottom)
@ViewBuilder
private var comparisonStatsHeader: some View {
let yearA = selectedSeries[0]
let yearB = selectedSeries[1]
let diffs = monthDiffs(yearA: yearA, yearB: yearB)
let validDiffs = diffs.compactMap { $0 }
let avgDiff = validDiffs.isEmpty ? 0 : validDiffs.reduce(0, +) / Double(validDiffs.count)
let endAInfo = endValue(yearA)
let endBInfo = endValue(yearB)
let endDiff = endBInfo.value - endAInfo.value
let endEstimated = endAInfo.estimated || endBInfo.estimated
let bestDiff = validDiffs.max() ?? 0
let worstDiff = validDiffs.min() ?? 0
VStack(alignment: .leading, spacing: 8) {
Text("\(yearA.year) vs \(yearB.year)")
.font(.subheadline.weight(.semibold))
ChartStatsRow(stats: [
ChartStat(label: "Avg diff", value: String(format: "%+.1f%%", avgDiff),
color: avgDiff >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "End diff", value: String(format: "%+.1f%%", endDiff) + (endEstimated ? "*" : ""),
color: endDiff >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best month", value: String(format: "%+.1f%%", bestDiff),
color: .positiveGreen),
ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff),
color: .negativeRed),
], showsOnRegularWidth: true)
}
}
@ViewBuilder
private var comparisonTable: some View {
let yearA = selectedSeries[0]
let yearB = selectedSeries[1]
ChartDataTable(
rows: comparisonTableRows(yearA: yearA, yearB: yearB),
valueHeader: String(yearA.year),
deltaPrevHeader: String(yearB.year),
deltaFirstHeader: "Diff"
)
}
// MARK: - Helpers
/// Effective year-end value: the forecast (estimated) for the current incomplete year,
/// otherwise the last month with real data.
private func endValue(_ s: ChartsViewModel.YearSeries) -> (value: Double, estimated: Bool) {
if let f = s.forecastEndValue { return (f, true) }
return (s.values.last(where: { !$0.isNaN }) ?? 0, false)
}
private var hasEstimate: Bool {
selectedSeries.contains { $0.forecastEndValue != nil }
}
private func monthDiffs(yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries) -> [Double?] {
(0..<12).map { idx in
let a = yearA.values[idx]
let b = yearB.values[idx]
guard !a.isNaN, !b.isNaN else { return nil }
return b - a
}
}
private func comparisonTableRows(
yearA: ChartsViewModel.YearSeries,
yearB: ChartsViewModel.YearSeries
) -> [ChartDataTableRow] {
(0..<12).compactMap { idx in
let valA = yearA.values[idx]
let valB = yearB.values[idx]
guard !valA.isNaN || !valB.isNaN else { return nil }
let diff = (!valA.isNaN && !valB.isNaN) ? valB - valA : 0
return ChartDataTableRow(
label: monthAbbreviations[idx],
value: valA.isNaN ? "" : String(format: "%+.1f%%", valA),
deltaPrev: valB.isNaN ? "" : String(format: "%+.1f%%", valB),
deltaFirst: (!valA.isNaN && !valB.isNaN) ? String(format: "%+.1f%%", diff) : "",
isPrevPositive: valB >= 0,
isFirstPositive: diff >= 0
)
}
}
}