initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -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()
}
@@ -0,0 +1,811 @@
import SwiftUI
import Charts
struct ChartsContainerView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: ChartsViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
// Chart Type Selector
chartTypeSelector
// Time Range Selector
if viewModel.selectedChartType != .allocation &&
viewModel.selectedChartType != .performance &&
viewModel.selectedChartType != .riskReturn {
timeRangeSelector
}
// Category Filter
if viewModel.selectedChartType == .evolution ||
viewModel.selectedChartType == .prediction {
categoryFilter
}
// Chart Content
chartContent
}
.padding()
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// Performance: Use onChange instead of onReceive for cleaner state updates
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
goalsViewModel.selectedAccount = newAccount
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: accountStore.showAllAccounts) { _, showAll in
viewModel.showAllAccounts = showAll
goalsViewModel.showAllAccounts = showAll
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: goalsViewModel.goals) { _, goals in
viewModel.updatePredictionTargetDate(goals)
}
}
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in
ChartTypeButton(
chartType: chartType,
isSelected: viewModel.selectedChartType == chartType,
isPremium: chartType.isPremium,
userIsPremium: viewModel.isPremium
) {
viewModel.selectChart(chartType)
}
}
}
.padding(.horizontal, 4)
}
}
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
HStack(spacing: 8) {
ForEach(ChartsViewModel.TimeRange.allCases) { range in
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
viewModel.selectedTimeRange == range
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedTimeRange == range
? .white
: .primary
)
.cornerRadius(20)
}
}
}
}
// MARK: - Category Filter
@ViewBuilder
private var categoryFilter: some View {
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
if !availableCategories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableCategories.count > 1 {
Button {
viewModel.selectedCategory = nil
} label: {
Text("All")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(availableCategories) { category in
Button {
viewModel.selectedCategory = category
} label: {
HStack(spacing: 4) {
Circle()
.fill(category.color)
.frame(width: 8, height: 8)
Text(category.name)
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory?.id == category.id
? category.color
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory?.id == category.id
? .white
: .primary
)
.cornerRadius(16)
}
}
}
}
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.isLoading {
ProgressView()
.frame(height: 300)
} else if !viewModel.hasData {
emptyStateView
} else {
switch viewModel.selectedChartType {
case .evolution:
EvolutionChartView(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals
)
case .allocation:
AllocationPieChart(data: viewModel.allocationData)
case .performance:
PerformanceBarChart(data: viewModel.performanceData)
case .contributions:
ContributionsChartView(data: viewModel.contributionsData)
case .rollingReturn:
RollingReturnChartView(data: viewModel.rollingReturnData)
case .riskReturn:
RiskReturnChartView(data: viewModel.riskReturnData)
case .cashflow:
CashflowStackedChartView(data: viewModel.cashflowData)
case .drawdown:
DrawdownChart(data: viewModel.drawdownData)
case .volatility:
VolatilityChartView(data: viewModel.volatilityData)
case .prediction:
PredictionChartView(
predictions: viewModel.predictionData,
historicalData: viewModel.evolutionData
)
}
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No Data Available")
.font(.headline)
Text("Add some investment sources and snapshots to see charts.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 300)
}
private var accountFilterMenu: some View {
Menu {
Button {
accountStore.selectAllAccounts()
} label: {
HStack {
Text("All Accounts")
if accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(accountStore.accounts) { account in
Button {
accountStore.selectAccount(account)
} label: {
HStack {
Text(account.name)
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: "person.2.circle")
}
}
}
// MARK: - Chart Type Button
struct ChartTypeButton: View {
let chartType: ChartsViewModel.ChartType
let isSelected: Bool
let isPremium: Bool
let userIsPremium: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
ZStack(alignment: .topTrailing) {
Image(systemName: chartType.icon)
.font(.title2)
if isPremium && !userIsPremium {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.offset(x: 8, y: -4)
}
}
Text(chartType.rawValue)
.font(.caption)
}
.frame(width: 80, height: 70)
.background(
isSelected
? LinearGradient.appPrimaryGradient
: LinearGradient(
colors: [
Color(.systemBackground).opacity(0.85),
Color(.systemBackground).opacity(0.85)
],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
}
// MARK: - Evolution Chart View
struct EvolutionChartView: View {
let data: [(date: Date, value: Decimal)]
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = false
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
case byCategory = "By Category"
var id: String { rawValue }
}
private var stackedCategoryData: [CategoryEvolutionPoint] {
guard !categoryData.isEmpty else { return [] }
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
result[point.categoryName, default: 0] += point.value
}
let categoryOrder = totalsByCategory
.sorted { $0.value > $1.value }
.map { $0.key }
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
let sortedDates = groupedByDate.keys.sorted()
var stacked: [CategoryEvolutionPoint] = []
for date in sortedDates {
guard let points = groupedByDate[date] else { continue }
let sortedPoints = points.sorted {
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
}
var running: Decimal = 0
for point in sortedPoints {
running += point.value
stacked.append(CategoryEvolutionPoint(
date: point.date,
categoryName: point.categoryName,
colorHex: point.colorHex,
value: running
))
}
}
return stacked
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
modePicker
chartSection
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
.font(.headline)
Spacer()
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing, spacing: 2) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
}
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
}
}
private var modePicker: some View {
Picker("Evolution Mode", selection: $chartMode) {
ForEach(ChartMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var chartSection: some View {
if data.count >= 2 {
chartView
} else {
Text("Not enough data")
.foregroundColor(.secondary)
.frame(height: 300)
}
}
private var chartView: some View {
Chart {
chartMarks
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.chartOverlay { proxy in
if chartMode == .total {
GeometryReader { geometry in
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let plotFrameAnchor = proxy.plotFrame else { return }
let plotFrame = geometry[plotFrameAnchor]
let x = value.location.x - plotFrame.origin.x
guard let date: Date = proxy.value(atX: x) else { return }
if let closest = data.min(by: {
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
}) {
selectedDataPoint = closest
}
}
.onEnded { _ in
selectedDataPoint = nil
}
)
}
}
}
.frame(height: 300)
// Performance: Use GPU rendering for smoother scrolling on older devices
.drawingGroup()
}
@ChartContentBuilder
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(30)
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
}
case .byCategory:
ForEach(categoryData) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
.opacity(0.5)
}
ForEach(stackedCategoryData) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.symbolSize(18)
}
}
if showGoalLines {
ForEach(goals) { goal in
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
.foregroundStyle(Color.appSecondary.opacity(0.4))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
}
}
}
private var chartCategoryNames: [String] {
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
return names
}
private var chartCategoryColors: [Color] {
chartCategoryNames.map { name in
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
}
// MARK: - Contributions Chart
struct ContributionsChartView: View {
let data: [(date: Date, amount: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Contributions")
.font(.headline)
if data.isEmpty {
Text("No contributions yet.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
BarMark(
x: .value("Month", item.date),
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.cornerRadius(6)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Rolling 12-Month Return
struct RollingReturnChartView: View {
let data: [(date: Date, value: Double)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rolling 12-Month Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data for rolling returns.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(24)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Risk vs Return
struct RiskReturnChartView: View {
let data: [(category: String, cagr: Double, volatility: Double, color: String)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Risk vs Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare categories.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.category) { item in
PointMark(
x: .value("Volatility", item.volatility),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.symbolSize(60)
.annotation(position: .top) {
Text(item.category)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
.chartXAxis {
AxisMarks(position: .bottom) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Net Performance vs Contributions
struct CashflowStackedChartView: View {
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Net Performance vs Contributions")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare cashflow.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue
let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue
let stackedEnd = contributionValue + netValue
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", 0),
yEnd: .value("Contributions", contributionValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.8))
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", contributionValue),
yEnd: .value("Net", stackedEnd)
)
.foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
ChartsContainerView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,304 @@
import SwiftUI
import Charts
struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
var maxDrawdown: Double {
abs(data.map { $0.drawdown }.min() ?? 0)
}
var currentDrawdown: Double {
abs(data.last?.drawdown ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(
LinearGradient(
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
LineMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.interpolationMethod(.catmullRom)
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
// Statistics
HStack(spacing: 20) {
DrawdownStatView(
title: "Current",
value: String(format: "%.1f%%", currentDrawdown),
isHighlighted: currentDrawdown > maxDrawdown * 0.8
)
DrawdownStatView(
title: "Maximum",
value: String(format: "%.1f%%", maxDrawdown),
isHighlighted: true
)
DrawdownStatView(
title: "Average",
value: String(format: "%.1f%%", averageDrawdown),
isHighlighted: false
)
}
.padding(.top, 8)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var averageDrawdown: Double {
guard !data.isEmpty else { return 0 }
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
return sum / Double(data.count)
}
}
struct DrawdownStatView: View {
let title: String
let value: String
let isHighlighted: Bool
var body: some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Volatility Chart View
struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
var currentVolatility: Double {
data.last?.volatility ?? 0
}
var averageVolatility: Double {
guard !data.isEmpty else { return 0 }
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Current")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", currentVolatility))
.font(.subheadline.weight(.semibold))
.foregroundColor(volatilityColor(currentVolatility))
}
}
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
// Average line
RuleMark(y: .value("Average", averageVolatility))
.foregroundStyle(Color.gray.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
// Volatility interpretation
HStack(spacing: 16) {
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
}
.padding(.top, 8)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func volatilityColor(_ volatility: Double) -> Color {
switch volatility {
case 0..<10:
return .positiveGreen
case 10..<20:
return .appWarning
default:
return .negativeRed
}
}
}
struct VolatilityLevelView: View {
let level: String
let range: String
let color: Color
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 0) {
Text(level)
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
#Preview {
let drawdownData: [(date: Date, drawdown: Double)] = [
(Date().adding(months: -6), -5),
(Date().adding(months: -5), -8),
(Date().adding(months: -4), -3),
(Date().adding(months: -3), -15),
(Date().adding(months: -2), -10),
(Date().adding(months: -1), -7),
(Date(), -4)
]
let volatilityData: [(date: Date, volatility: Double)] = [
(Date().adding(months: -6), 12),
(Date().adding(months: -5), 15),
(Date().adding(months: -4), 10),
(Date().adding(months: -3), 22),
(Date().adding(months: -2), 18),
(Date().adding(months: -1), 14),
(Date(), 11)
]
return VStack(spacing: 20) {
DrawdownChart(data: drawdownData)
VolatilityChartView(data: volatilityData)
}
.padding()
}
@@ -0,0 +1,159 @@
import SwiftUI
import Charts
struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
.font(.headline)
Text("Compound Annual Growth Rate (CAGR)")
.font(.caption)
.foregroundColor(.secondary)
if !data.isEmpty {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .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)
}
}
}
}
.frame(height: 250)
// Legend / Details
VStack(spacing: 8) {
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
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))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
}
.padding(.top, 8)
} else {
Text("No performance data available")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Horizontal Bar Version
struct HorizontalPerformanceChart: View {
let data: [(category: String, cagr: Double, color: String)]
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) {
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))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .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)
.cornerRadius(4)
Rectangle()
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
.frame(width: barWidth, height: 8)
.cornerRadius(4)
}
}
.frame(height: 8)
}
}
}
.padding()
.background(Color(.systemBackground))
.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()
}
@@ -0,0 +1,265 @@
import SwiftUI
import Charts
struct PredictionChartView: View {
let predictions: [Prediction]
let historicalData: [(date: Date, value: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
Spacer()
if let lastPrediction = predictions.last {
VStack(alignment: .trailing, spacing: 2) {
Text("Forecast")
.font(.caption)
.foregroundColor(.secondary)
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
}
if let algorithm = predictions.first?.algorithm {
Text("Algorithm: \(algorithm.displayName)")
.font(.caption)
.foregroundColor(.secondary)
}
if !predictions.isEmpty && historicalData.count >= 2 {
Chart {
// Historical data
ForEach(historicalData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(24)
}
// Confidence interval area
ForEach(predictions) { prediction in
AreaMark(
x: .value("Date", prediction.date),
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.2))
}
// Prediction line
ForEach(predictions) { prediction in
LineMark(
x: .value("Date", prediction.date),
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
PointMark(
x: .value("Date", prediction.date),
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.symbolSize(24)
}
// Connect historical to prediction
if let lastHistorical = historicalData.last,
let firstPrediction = predictions.first {
LineMark(
x: .value("Date", lastHistorical.date),
y: .value("Value", NSDecimalNumber(decimal: lastHistorical.value).doubleValue),
series: .value("Connection", "connect")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
LineMark(
x: .value("Date", firstPrediction.date),
y: .value("Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue),
series: .value("Connection", "connect")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.frame(height: 280)
// Legend
HStack(spacing: 20) {
HStack(spacing: 6) {
Rectangle()
.fill(Color.appPrimary)
.frame(width: 20, height: 3)
Text("Historical")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.appSecondary)
.frame(width: 20, height: 3)
.mask(
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { _ in
Rectangle()
.frame(width: 3)
}
}
)
Text("Prediction")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.appSecondary.opacity(0.3))
.frame(width: 20, height: 10)
Text("Confidence")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Prediction details
if let lastPrediction = predictions.last {
Divider()
.padding(.vertical, 8)
VStack(spacing: 12) {
HStack {
Text("12-Month Forecast")
.font(.subheadline)
Spacer()
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
}
HStack {
Text("Confidence Range")
.font(.subheadline)
Spacer()
Text(lastPrediction.formattedConfidenceRange)
.font(.caption)
.foregroundColor(.secondary)
}
if let currentValue = historicalData.last?.value {
let change = lastPrediction.predictedValue - currentValue
let changePercent = currentValue > 0
? NSDecimalNumber(decimal: change / currentValue).doubleValue * 100
: 0
HStack {
Text("Expected Change")
.font(.subheadline)
Spacer()
HStack(spacing: 4) {
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%+.1f%%", changePercent))
.font(.subheadline.weight(.medium))
}
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
}
}
}
}
} else {
VStack(spacing: 12) {
Image(systemName: "wand.and.stars")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("Not enough data for predictions")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Add at least 3 snapshots to generate predictions")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 280)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let historicalData: [(date: Date, value: Decimal)] = [
(Date().adding(months: -6), 10000),
(Date().adding(months: -5), 10500),
(Date().adding(months: -4), 10200),
(Date().adding(months: -3), 11000),
(Date().adding(months: -2), 11500),
(Date().adding(months: -1), 11200),
(Date(), 12000)
]
let predictions = [
Prediction(
date: Date().adding(months: 3),
predictedValue: 13000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 12000, upper: 14000)
),
Prediction(
date: Date().adding(months: 6),
predictedValue: 14000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 12500, upper: 15500)
),
Prediction(
date: Date().adding(months: 9),
predictedValue: 15000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 13000, upper: 17000)
),
Prediction(
date: Date().adding(months: 12),
predictedValue: 16000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 13500, upper: 18500)
)
]
return PredictionChartView(predictions: predictions, historicalData: historicalData)
.padding()
}