d38c6943e1
Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu como navegación primaria), sin bubbles permanentes en compact (solo endpoint), Performance a barras horizontales con >6 categorías, línea con gradiente y estética de widget, pinch anclado al centro + doble-tap reset + haptics. Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush; minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con haptic); drill-down por tap en Allocation/Performance → Evolution filtrada (premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas (premium); insight contextual del rango visible (premium). L10n ×7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
401 lines
16 KiB
Swift
401 lines
16 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
|
|
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
|
|
/// filtered to that category/source.
|
|
var onDrillDown: ((String) -> Void)? = nil
|
|
|
|
@State private var selectedSlice: String?
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
private var chartHeight: CGFloat {
|
|
horizontalSizeClass == .regular ? 190 : 130
|
|
}
|
|
|
|
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: 24) {
|
|
// Hemicycle (parliament-style) allocation gauge — fills its
|
|
// half of the row so it scales up on iPad instead of sitting
|
|
// tiny in a corner.
|
|
SemicircleAllocation(
|
|
data: data,
|
|
total: total,
|
|
selectedSlice: $selectedSlice
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: chartHeight)
|
|
|
|
// Legend
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(data, id: \.category) { item in
|
|
HStack(spacing: 6) {
|
|
Button {
|
|
if selectedSlice == item.category {
|
|
selectedSlice = nil
|
|
} else {
|
|
selectedSlice = item.category
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(Color(hex: item.color) ?? .gray)
|
|
.frame(width: 11, height: 11)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(item.category)
|
|
.font(.subheadline)
|
|
.foregroundColor(.primary)
|
|
|
|
let percentage = total > 0
|
|
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
|
: 0
|
|
Text(String(format: "%.1f%%", percentage))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if let onDrillDown {
|
|
Button {
|
|
onDrillDown(item.category)
|
|
} label: {
|
|
Image(systemName: "chevron.right.circle.fill")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary.opacity(0.6))
|
|
}
|
|
.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
|
|
// The semicircle is 2:1 (width:height). Fit it to whichever
|
|
// dimension binds so it scales up on iPad without clipping.
|
|
let w = min(geo.size.width, geo.size.height * 2)
|
|
let lineWidth = w * 0.17
|
|
let radius = (w - lineWidth) / 2
|
|
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
|
|
|
|
ZStack {
|
|
// Track
|
|
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
|
|
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
|
|
|
ForEach(segments, id: \.category) { seg in
|
|
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, 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, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
|
|
.onTapGesture {
|
|
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
|
|
}
|
|
}
|
|
|
|
// Percentage labels on the arc band (only where the slice is wide
|
|
// enough to fit the text).
|
|
ForEach(segments, id: \.category) { seg in
|
|
let frac = seg.end - seg.start
|
|
if frac >= 0.07 {
|
|
let mid = (seg.start + seg.end) / 2
|
|
let angle = (180 + mid * 180) * .pi / 180
|
|
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
|
|
y: center.y + radius * CoreGraphics.sin(angle))
|
|
Text("\(Int((frac * 100).rounded()))%")
|
|
.font(.system(size: lineWidth * 0.42, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.position(pos)
|
|
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
|
}
|
|
}
|
|
|
|
// 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.25)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 center: CGPoint? = nil
|
|
var inset: Double = 0
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let c = 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: c, 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()
|
|
}
|