197bddcd7e
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0): - GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las imágenes compartidas llevaba a una página muerta. Corregido a id6757678318. - SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante. Monetización: - IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos. Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime mientras el producto no esté creado en App Store Connect). - paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores). - Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner). - Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups"). Retención / adquisición: - Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings). - Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío). - Notificación de protección de streak el día 25 si el check-in del mes está pendiente. - App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio" vía Siri/Shortcuts/Spotlight. - Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop). - ScreenshotMode (--screenshots) para capturas de marketing automatizadas. ASO: - 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma). - Categoría secundaria: PRODUCTIVITY. - 17 strings nuevas localizadas en los 7 idiomas. Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción + intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
1425 lines
55 KiB
Swift
1425 lines
55 KiB
Swift
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
|
|
@AppStorage("showForecast") private var showForecast = true
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
init(iapService: IAPService) {
|
|
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if horizontalSizeClass == .regular {
|
|
iPadChartsLayout
|
|
} else {
|
|
iPhoneChartsLayout
|
|
}
|
|
}
|
|
.navigationTitle("Charts")
|
|
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
|
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
|
|
}
|
|
.onAppear { syncState() }
|
|
.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: - Focus Mode Button
|
|
|
|
private var focusModeButton: some View {
|
|
Button {
|
|
calmModeEnabled.toggle()
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
Text(calmModeEnabled ? "Focus" : "Full")
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
|
|
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
.cornerRadius(14)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - iPad Layout (sidebar + full-width chart)
|
|
|
|
private var iPadChartsLayout: some View {
|
|
HStack(spacing: 0) {
|
|
// Left sidebar: chart type list + filters
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Chart Type")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
|
|
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
|
|
.filter { showForecast || $0 != .prediction }
|
|
ForEach(types) { chartType in
|
|
iPadChartTypeRow(chartType)
|
|
}
|
|
|
|
Divider().padding(.vertical, 8)
|
|
|
|
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
|
|
Button {
|
|
calmModeEnabled.toggle()
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(calmModeEnabled ? .white : .primary)
|
|
}
|
|
Text("Focus Mode")
|
|
.font(.subheadline)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
.font(.system(size: 16))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if hasAnyFilter {
|
|
Divider().padding(.vertical, 12)
|
|
filtersSection
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 220)
|
|
.background(Color(.systemBackground))
|
|
|
|
Divider()
|
|
|
|
// Right panel: full-width chart
|
|
ZStack {
|
|
AppBackground()
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
if !viewModel.isPremium {
|
|
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
|
.onAppear {
|
|
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
|
}
|
|
}
|
|
chartContent
|
|
if viewModel.hiddenHistoryMonths > 0 {
|
|
lockedHistoryTeaser
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
|
}
|
|
|
|
/// Concrete loss-framing upsell: the user has real data older than the free
|
|
/// 12-month window; tell them exactly how much is locked instead of hiding it.
|
|
private var lockedHistoryTeaser: some View {
|
|
Button {
|
|
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
|
|
viewModel.showingPaywall = true
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "lock.fill")
|
|
Text(String(format: String(localized: "chart_history_locked"), viewModel.hiddenHistoryMonths))
|
|
.multilineTextAlignment(.leading)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
}
|
|
.font(.footnote.weight(.medium))
|
|
.foregroundColor(.appPrimary)
|
|
.padding(12)
|
|
.background(Color.appPrimary.opacity(0.08))
|
|
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - iPhone Layout
|
|
|
|
private var iPhoneChartsLayout: some View {
|
|
ZStack {
|
|
AppBackground()
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
chartTypeSelector
|
|
if !viewModel.isPremium {
|
|
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
|
.onAppear {
|
|
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
|
}
|
|
}
|
|
filtersSection
|
|
chartContent
|
|
if viewModel.hiddenHistoryMonths > 0 {
|
|
lockedHistoryTeaser
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - iPad Chart Type Row
|
|
|
|
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
|
|
let isSelected = viewModel.selectedChartType == chartType
|
|
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
|
|
return Button {
|
|
viewModel.selectChart(chartType)
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: chartType.icon)
|
|
.font(.system(size: 14))
|
|
.foregroundColor(isSelected ? .white : .primary)
|
|
}
|
|
Text(chartType.rawValue)
|
|
.font(.subheadline)
|
|
.foregroundColor(isPremiumLocked ? .secondary : .primary)
|
|
Spacer()
|
|
if isPremiumLocked {
|
|
Image(systemName: "lock.fill")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - Shared
|
|
|
|
private func syncState() {
|
|
viewModel.selectedAccount = accountStore.selectedAccount
|
|
viewModel.showAllAccounts = accountStore.showAllAccounts
|
|
viewModel.loadData()
|
|
goalsViewModel.selectedAccount = accountStore.selectedAccount
|
|
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
|
|
goalsViewModel.refresh()
|
|
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
|
}
|
|
|
|
// MARK: - Chart Type Selector
|
|
|
|
private var chartTypeSelector: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in
|
|
ChartTypeButton(
|
|
chartType: chartType,
|
|
isSelected: viewModel.selectedChartType == chartType,
|
|
isPremium: chartType.isPremium,
|
|
userIsPremium: viewModel.isPremium
|
|
) {
|
|
viewModel.selectChart(chartType)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Unified Filters Section
|
|
|
|
private var hasAnyFilter: Bool {
|
|
let chartType = viewModel.selectedChartType
|
|
// These chart types manage their own controls internally, no external filter bar needed
|
|
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
|
|
return !viewModel.availableTimeRanges(for: chartType).isEmpty
|
|
}
|
|
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
|
|
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
|
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
|
let hasBreakdown = chartType == .allocation || chartType == .performance
|
|
return hasTimeRange || hasCategories || hasSources || hasBreakdown
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var filtersSection: some View {
|
|
if hasAnyFilter {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
let chartType = viewModel.selectedChartType
|
|
|
|
// Time Range (not for performance — uses slider)
|
|
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
|
|
filterRow(icon: "calendar", label: "Period") {
|
|
timeRangeSelector
|
|
}
|
|
}
|
|
|
|
// Performance: custom period slider
|
|
if chartType == .performance {
|
|
filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") {
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(viewModel.performancePeriodMonths) },
|
|
set: { viewModel.performancePeriodMonths = Int($0) }
|
|
),
|
|
in: 1...60,
|
|
step: 1
|
|
)
|
|
.tint(.appPrimary)
|
|
}
|
|
}
|
|
|
|
// Breakdown (category vs source)
|
|
if chartType == .allocation || chartType == .performance {
|
|
filterRow(icon: "square.grid.2x2", label: "Group") {
|
|
breakdownSelector
|
|
}
|
|
}
|
|
|
|
// Category
|
|
let availableCategories = viewModel.availableCategories(for: chartType)
|
|
if !availableCategories.isEmpty {
|
|
filterRow(icon: "tag", label: "Category") {
|
|
categoryFilter
|
|
}
|
|
}
|
|
|
|
// Source
|
|
let availableSources = viewModel.availableSources(for: chartType)
|
|
if !availableSources.isEmpty {
|
|
filterRow(icon: "building.2", label: "Source") {
|
|
sourceFilter
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
|
}
|
|
}
|
|
|
|
private func filterRow<Content: View>(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
Text(label)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
content()
|
|
}
|
|
}
|
|
|
|
// MARK: - Time Range Selector
|
|
|
|
private var timeRangeSelector: some View {
|
|
Group {
|
|
if horizontalSizeClass == .regular {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
|
|
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
|
timeRangeButton(range, fullWidth: true)
|
|
}
|
|
}
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
|
timeRangeButton(range, fullWidth: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View {
|
|
Button {
|
|
viewModel.selectedTimeRange = range
|
|
} label: {
|
|
Text(range.rawValue)
|
|
.font(.subheadline.weight(.medium))
|
|
.frame(maxWidth: fullWidth ? .infinity : nil)
|
|
.padding(.horizontal, fullWidth ? 8 : 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 {
|
|
if horizontalSizeClass == .regular {
|
|
VStack(spacing: 4) {
|
|
if availableCategories.count > 1 {
|
|
categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) {
|
|
viewModel.selectedCategory = nil
|
|
}
|
|
}
|
|
ForEach(availableCategories) { category in
|
|
categoryPill(label: category.name, color: category.color,
|
|
isSelected: viewModel.selectedCategory?.id == category.id) {
|
|
viewModel.selectedCategory = category
|
|
viewModel.selectedSource = nil
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
if availableCategories.count > 1 {
|
|
categoryPill(label: "All", color: .appPrimary,
|
|
isSelected: viewModel.selectedCategory == nil) {
|
|
viewModel.selectedCategory = nil
|
|
}
|
|
}
|
|
ForEach(availableCategories) { category in
|
|
categoryPill(label: category.name, color: category.color,
|
|
isSelected: viewModel.selectedCategory?.id == category.id) {
|
|
viewModel.selectedCategory = category
|
|
viewModel.selectedSource = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(label)
|
|
.font(.caption.weight(.medium))
|
|
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(isSelected ? color : Color.gray.opacity(0.1))
|
|
.foregroundColor(isSelected ? .white : .primary)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
|
|
// MARK: - Source Filter
|
|
|
|
@ViewBuilder
|
|
private var sourceFilter: some View {
|
|
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
|
|
if !availableSources.isEmpty {
|
|
if horizontalSizeClass == .regular {
|
|
VStack(spacing: 4) {
|
|
if availableSources.count > 1 {
|
|
sourcePill(label: "All Sources", color: .appPrimary,
|
|
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
|
|
viewModel.selectedSource = nil
|
|
viewModel.selectedSourceIds.removeAll()
|
|
}
|
|
}
|
|
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
|
let isSelected = viewModel.selectedSourceIds.contains(source.id)
|
|
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
|
|
viewModel.selectedSource = nil
|
|
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
|
|
else { viewModel.selectedSourceIds.insert(source.id) }
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
if availableSources.count > 1 {
|
|
sourcePill(label: "All Sources", color: .appPrimary,
|
|
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
|
|
viewModel.selectedSource = nil
|
|
viewModel.selectedSourceIds.removeAll()
|
|
}
|
|
}
|
|
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
|
let isSelected = viewModel.selectedSourceIds.contains(source.id)
|
|
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
|
|
viewModel.selectedSource = nil
|
|
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
|
|
else { viewModel.selectedSourceIds.insert(source.id) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(label)
|
|
.font(.caption.weight(.medium))
|
|
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(isSelected ? color : Color.gray.opacity(0.1))
|
|
.foregroundColor(isSelected ? .white : .primary)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
|
|
// MARK: - Breakdown Selector
|
|
|
|
private var breakdownSelector: some View {
|
|
Group {
|
|
if horizontalSizeClass == .regular {
|
|
VStack(spacing: 4) {
|
|
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
|
breakdownButton(mode)
|
|
}
|
|
}
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
|
breakdownButton(mode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View {
|
|
Button {
|
|
viewModel.selectedBreakdown = mode
|
|
} label: {
|
|
Text(mode.rawValue)
|
|
.font(.subheadline.weight(.medium))
|
|
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1))
|
|
.foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary)
|
|
.cornerRadius(18)
|
|
}
|
|
}
|
|
|
|
private func periodLabel(_ months: Int) -> String {
|
|
if months < 12 {
|
|
return "\(months)M"
|
|
} else if months % 12 == 0 {
|
|
return "\(months / 12)Y"
|
|
} else {
|
|
return "\(months / 12)Y \(months % 12)M"
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
VStack(spacing: 20) {
|
|
AllocationPieChart(
|
|
data: viewModel.allocationData,
|
|
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
|
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
|
)
|
|
|
|
if !viewModel.allocationEvolutionData.isEmpty {
|
|
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
|
|
}
|
|
}
|
|
case .performance:
|
|
PerformanceBarChart(
|
|
data: viewModel.performanceData,
|
|
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
|
)
|
|
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
|
|
)
|
|
case .yearOverYear:
|
|
YearOverYearChartView(viewModel: viewModel)
|
|
case .comparison:
|
|
ComparisonChartView(viewModel: viewModel)
|
|
case .simulator:
|
|
AllocationSimulatorView(viewModel: viewModel)
|
|
case .periodComparison:
|
|
PeriodComparisonChartView(viewModel: viewModel)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(availableAccounts, id: \.objectID) { account in
|
|
Button {
|
|
accountStore.selectAccount(account)
|
|
} label: {
|
|
HStack {
|
|
Text(account.name)
|
|
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "person.2.circle")
|
|
}
|
|
}
|
|
|
|
private var availableAccounts: [Account] {
|
|
accountStore.accounts.filter { $0.safeId != nil }
|
|
}
|
|
}
|
|
|
|
// 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 static let compactXAxisDateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = .autoupdatingCurrent
|
|
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
|
|
return formatter
|
|
}()
|
|
|
|
private var xAxisMonthStride: Int {
|
|
switch data.count {
|
|
case ...8:
|
|
return 1
|
|
case ...16:
|
|
return 2
|
|
case ...30:
|
|
return 3
|
|
case ...48:
|
|
return 4
|
|
default:
|
|
return 6
|
|
}
|
|
}
|
|
|
|
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
|
|
if !data.isEmpty && chartMode == .total {
|
|
ChartStatsRow(stats: evolutionStats)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var evolutionStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
|
let latest = values.last ?? 0
|
|
let first = values.first ?? 0
|
|
let change = latest - first
|
|
let changePct = first > 0 ? (change / first) * 100 : 0
|
|
let maxVal = values.max() ?? 0
|
|
let minVal = values.min() ?? 0
|
|
return [
|
|
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
|
|
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
|
|
ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen),
|
|
ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed),
|
|
]
|
|
}
|
|
|
|
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: xAxisMonthStride)) { value in
|
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
|
|
.foregroundStyle(Color.secondary.opacity(0.2))
|
|
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
|
|
.foregroundStyle(Color.secondary.opacity(0.28))
|
|
AxisValueLabel {
|
|
if let date = value.as(Date.self) {
|
|
Text(date, formatter: Self.compactXAxisDateFormatter)
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var contributionStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
|
|
let total = amounts.reduce(0, +)
|
|
let avg = total / Double(amounts.count)
|
|
let highest = amounts.max() ?? 0
|
|
let last = amounts.last ?? 0
|
|
return [
|
|
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
|
|
ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary),
|
|
ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen),
|
|
ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary),
|
|
]
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
|
ChartDataTable(
|
|
rows: rollingTableRows,
|
|
valueHeader: "Return",
|
|
deltaPrevHeader: "Δ prev",
|
|
deltaFirstHeader: "Δ first"
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var rollingStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let values = data.map { $0.value }
|
|
let latest = values.last ?? 0
|
|
let best = values.max() ?? 0
|
|
let worst = values.min() ?? 0
|
|
let avg = values.reduce(0, +) / Double(values.count)
|
|
let change = (values.last ?? 0) - (values.first ?? 0)
|
|
return [
|
|
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
|
|
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
|
|
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
|
|
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
|
ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed),
|
|
]
|
|
}
|
|
|
|
private var rollingTableRows: [ChartDataTableRow] {
|
|
guard !data.isEmpty else { return [] }
|
|
let firstVal = data.first?.value ?? 0
|
|
return data.enumerated().map { idx, point in
|
|
let prevVal = idx > 0 ? data[idx - 1].value : point.value
|
|
let deltaPrev = point.value - prevVal
|
|
let deltaFirst = point.value - firstVal
|
|
return ChartDataTableRow(
|
|
label: chartTableDateLabel(point.date),
|
|
value: String(format: "%.1f%%", point.value),
|
|
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
|
|
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
|
|
isPrevPositive: deltaPrev >= 0,
|
|
isFirstPositive: deltaFirst >= 0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var cashflowStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions }
|
|
let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance }
|
|
let net = totalContrib + totalPerf
|
|
return [
|
|
ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary),
|
|
ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed),
|
|
ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed),
|
|
]
|
|
}
|
|
}
|
|
|
|
// MARK: - Allocation Evolution Chart
|
|
|
|
struct AllocationEvolutionDataPoint: Identifiable {
|
|
let id: String
|
|
let date: Date
|
|
let category: String
|
|
let percentage: Double
|
|
let color: String
|
|
}
|
|
|
|
struct AllocationEvolutionChart: View {
|
|
let data: [(date: Date, category: String, percentage: Double, color: String)]
|
|
|
|
private var identifiableData: [AllocationEvolutionDataPoint] {
|
|
data.enumerated().map { index, item in
|
|
AllocationEvolutionDataPoint(
|
|
id: "\(index)-\(item.category)",
|
|
date: item.date,
|
|
category: item.category,
|
|
percentage: item.percentage,
|
|
color: item.color
|
|
)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Allocation Over Time")
|
|
.font(.headline)
|
|
|
|
if data.isEmpty {
|
|
emptyView
|
|
} else {
|
|
chartView
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
Text("Not enough data to show allocation evolution.")
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 260)
|
|
}
|
|
|
|
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
|
|
private var stableCategoryNames: [String] {
|
|
var seen = Set<String>()
|
|
var ordered: [String] = []
|
|
for item in data {
|
|
if seen.insert(item.category).inserted {
|
|
ordered.append(item.category)
|
|
}
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
private var stableCategoryColors: [Color] {
|
|
stableCategoryNames.map { name in
|
|
if let hex = data.first(where: { $0.category == name })?.color {
|
|
return Color(hex: hex) ?? .gray
|
|
}
|
|
return .gray
|
|
}
|
|
}
|
|
|
|
private var chartView: some View {
|
|
Chart(identifiableData) { item in
|
|
BarMark(
|
|
x: .value("Date", item.date, unit: .month),
|
|
y: .value("Percentage", item.percentage)
|
|
)
|
|
.foregroundStyle(by: .value("Category", item.category))
|
|
}
|
|
.chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
|
|
.chartXAxis {
|
|
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
|
|
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisValueLabel {
|
|
if let pct = value.as(Double.self) {
|
|
Text(String(format: "%.0f%%", pct))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 260)
|
|
.drawingGroup()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ChartsContainerView(iapService: IAPService())
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|