initial version
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
|
||||
@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("Asset Allocation")
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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: - 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()
|
||||
}
|
||||
Reference in New Issue
Block a user