Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/AllocationPieChart.swift
T
alexandrev-tibco 18f2609d87 Fixes UI iPad + diagnóstico iCloud (build 57)
Feedback del usuario en iPad:
- Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento).
  SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape),
  segmentos por cuota en 180°, total/selección en el centro, tap por segmento.
- Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran
  KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo),
  Period (retorno final por periodo), YoY (retorno final por año seleccionado).
- Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact
  — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros.

iCloud:
- CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco.
  CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el
  código dominante → hint accionable (esquema/quota/red). Settings muestra el
  hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:16:24 +02:00

357 lines
14 KiB
Swift

import SwiftUI
import Charts
struct AllocationPieChart: View {
let data: [(category: String, value: Decimal, color: String)]
var title: String = "Asset Allocation"
var showsTargetsComparison: Bool = true
@State private var selectedSlice: String?
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.headline)
if !data.isEmpty {
HStack(alignment: .center, spacing: 20) {
// Hemicycle (parliament-style) allocation gauge
SemicircleAllocation(
data: data,
total: total,
selectedSlice: $selectedSlice
)
.frame(width: 200, height: 128)
// Legend
VStack(alignment: .leading, spacing: 8) {
ForEach(data, id: \.category) { item in
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.caption)
.foregroundColor(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption2)
.foregroundColor(.secondary)
}
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if showsTargetsComparison {
AllocationTargetsComparisonChart(data: data)
}
} else {
Text("No allocation data available")
.foregroundColor(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Semicircle (hemicycle) allocation gauge
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
struct SemicircleAllocation: View {
let data: [(category: String, value: Decimal, color: String)]
let total: Decimal
@Binding var selectedSlice: String?
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
guard total > 0 else { return [] }
var acc = 0.0
return data.map { item in
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
let start = acc
acc += frac
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
}
}
private var centerItem: (label: String, value: String, sub: String?)? {
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
}
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
}
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let lineWidth = w * 0.16
let radius = (w - lineWidth) / 2
let center = CGPoint(x: w / 2, y: w / 2) // arc baseline at bottom of the square-ish area
ZStack {
// Track
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius)
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
ForEach(segments, id: \.category) { seg in
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, inset: 0.006)
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius).stroke(style: StrokeStyle(lineWidth: lineWidth)))
.onTapGesture {
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
}
}
// Center readout, sitting just under the arc
if let c = centerItem {
VStack(spacing: 1) {
Text(c.label)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
Text(c.value)
.font(.headline)
if let sub = c.sub {
Text(sub)
.font(.caption2)
.foregroundColor(.secondary)
}
}
.position(x: center.x, y: center.y - lineWidth * 0.2)
}
}
}
}
}
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
struct SemicircleArc: Shape {
let startFraction: Double
let endFraction: Double
let radius: CGFloat
var inset: Double = 0
func path(in rect: CGRect) -> Path {
let center = CGPoint(x: rect.width / 2, y: rect.width / 2)
let start = Angle.degrees(180 + (startFraction + inset) * 180)
let end = Angle.degrees(180 + (endFraction - inset) * 180)
var p = Path()
p.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: false)
return p
}
}
// MARK: - Allocation Targets Comparison
struct AllocationTargetsComparisonChart: View {
let data: [(category: String, value: Decimal, color: String)]
@StateObject private var categoryRepository = CategoryRepository()
private var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
private var targetData: [(category: String, actual: Double, target: Double, color: Color)] {
data.map { item in
let actual = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
let target = AllocationTargetStore.target(for: categoryId(for: item.category)) ?? 0
let color = Color(hex: item.color) ?? .gray
return (item.category, actual, target, color)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Targets vs Actual")
.font(.headline)
if targetData.allSatisfy({ $0.target == 0 }) {
Text("Set allocation targets to compare your portfolio against your plan.")
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Chart {
ForEach(targetData, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("Actual", item.actual)
)
.foregroundStyle(item.color)
BarMark(
x: .value("Category", item.category),
y: .value("Target", item.target)
)
.foregroundStyle(Color.gray.opacity(0.35))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel()
}
}
.frame(height: 220)
ForEach(targetData, id: \.category) { item in
let drift = item.actual - item.target
let prefix = drift >= 0 ? "+" : ""
HStack {
Circle()
.fill(item.color)
.frame(width: 8, height: 8)
Text(item.category)
.font(.caption)
Spacer()
Text("Actual \(String(format: "%.1f%%", item.actual))")
.font(.caption2)
.foregroundColor(.secondary)
Text("Target \(String(format: "%.0f%%", item.target))")
.font(.caption2)
.foregroundColor(.secondary)
Text("\(prefix)\(String(format: "%.1f%%", drift))")
.font(.caption2.weight(.semibold))
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func categoryId(for name: String) -> UUID {
if let category = categoryRepository.categories.first(where: { $0.name == name }) {
return category.id
}
return UUID()
}
}
// MARK: - Allocation List View (Alternative)
struct AllocationListView: View {
let data: [(category: String, value: Decimal, color: String)]
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Asset Allocation")
.font(.headline)
ForEach(data, id: \.category) { item in
VStack(spacing: 4) {
HStack {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
}
Spacer()
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.subheadline.weight(.medium))
Text(item.value.compactCurrencyString)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 70, alignment: .trailing)
}
// Progress bar
GeometryReader { geometry in
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue
: 0
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
Rectangle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: geometry.size.width * percentage, height: 6)
.cornerRadius(3)
}
}
.frame(height: 6)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let sampleData: [(category: String, value: Decimal, color: String)] = [
("Stocks", 50000, "#10B981"),
("Bonds", 25000, "#3B82F6"),
("Real Estate", 15000, "#F59E0B"),
("Crypto", 10000, "#8B5CF6")
]
return VStack(spacing: 20) {
AllocationPieChart(data: sampleData)
AllocationListView(data: sampleData)
}
.padding()
}