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() }