Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/PerformanceBarChart.swift
T
alexandrev-tibco 680255f69d iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las
  size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o
  cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab.
- Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho
  regular, stack colapsable en compacto) con selección nativa de List.
- Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos
  en regular, sheet en compacto).
- Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:),
  sin ignoresSafeArea en contenido interactivo.
- Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00

260 lines
9.0 KiB
Swift

import SwiftUI
import Charts
struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)]
var title: String = "Performance by Category"
/// Drill-down: tapping a row jumps to Evolution filtered to it.
var onDrillDown: ((String) -> Void)? = nil
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// On iPhone, vertical bars collide once there are more than a handful of
/// categories (labels + annotations fight for width). Horizontal bars give
/// every name a full line.
private var useHorizontalBars: Bool {
horizontalSizeClass != .regular && data.count > 6
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.headline)
Text("Compound Annual Growth Rate (CAGR)")
.font(.caption)
.foregroundStyle(.secondary)
if !data.isEmpty {
ChartStatsRow(stats: perfStats)
}
if !data.isEmpty {
if useHorizontalBars {
horizontalChart
} else {
verticalChart
}
// Legend / Details rows drill down when the owner wires it up.
VStack(spacing: 8) {
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
legendRow(item)
}
}
.padding(.top, 8)
} else {
Text("No performance data available")
.foregroundStyle(.secondary)
.chartFrame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var verticalChart: some View {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartFrame(height: 250)
}
private var horizontalChart: some View {
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
BarMark(
x: .value("CAGR", item.cagr),
y: .value("Category", item.category)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.clipShape(RoundedRectangle(cornerRadius: 4))
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.frame(height: CGFloat(data.count) * 34 + 40)
}
@ViewBuilder
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
let row = HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
if onDrillDown != nil {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
if let onDrillDown {
Button {
onDrillDown(item.category)
} label: {
row.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
row
}
}
private var perfStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.cagr }
let best = values.max() ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
}
}
// MARK: - Horizontal Bar Version
struct HorizontalPerformanceChart: View {
let data: [(category: String, cagr: Double, color: String)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
var sortedData: [(category: String, cagr: Double, color: String)] {
data.sorted { $0.cagr > $1.cagr }
}
var maxValue: Double {
max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0))
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if showsTitle {
Text("Performance by Category")
.font(.headline)
}
ForEach(sortedData, id: \.category) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
}
GeometryReader { geometry in
let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0
let barWidth = geometry.size.width * normalizedValue
ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
Rectangle()
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
.frame(width: barWidth, height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(height: 8)
}
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let sampleData: [(category: String, cagr: Double, color: String)] = [
("Stocks", 12.5, "#10B981"),
("Bonds", 4.2, "#3B82F6"),
("Real Estate", 8.1, "#F59E0B"),
("Crypto", -5.3, "#8B5CF6")
]
return VStack(spacing: 20) {
PerformanceBarChart(data: sampleData)
HorizontalPerformanceChart(data: sampleData)
}
.padding()
}