f1aeafccf6
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el swipe entre charts (el brush vivía dentro de chartContent, bajo el simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de periodo, fuera del área con gesto. Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow, comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries (agregación mensual extraída de calculateEvolutionData como monthlySeries). Presets limpian el rango custom; selección completa = sin filtro. El pinch por-chart se mantiene como zoom visual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2027 lines
84 KiB
Swift
2027 lines
84 KiB
Swift
import SwiftUI
|
||
import Charts
|
||
import TipKit
|
||
|
||
struct ChartsContainerView: View {
|
||
@EnvironmentObject var accountStore: AccountStore
|
||
@StateObject private var viewModel: ChartsViewModel
|
||
@StateObject private var goalsViewModel = GoalsViewModel()
|
||
@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(horizontalSizeClass == .regular ? "Charts" : viewModel.selectedChartType.rawValue)
|
||
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .automatic : .inline)
|
||
// iPhone: the title doubles as the chart switcher (Files/Freeform
|
||
// pattern) — replaces the 14-chip horizontal carousel.
|
||
.toolbarTitleMenu { chartTypePicker }
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
|
||
if horizontalSizeClass != .regular {
|
||
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
||
}
|
||
}
|
||
.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: - iPad Layout (sidebar + full-width chart)
|
||
|
||
private var iPadChartsLayout: some View {
|
||
HStack(spacing: 0) {
|
||
// Left sidebar: native List selection (system hit-testing, keyboard
|
||
// navigation, pointer effects) — replaces the custom tile grid whose
|
||
// Buttons were unreliable inside NavigationSplitView.
|
||
List(selection: sidebarSelection) {
|
||
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||
let types = group.types.filter { showForecast || $0 != .prediction }
|
||
if !types.isEmpty {
|
||
Section(String(localized: String.LocalizationValue(group.titleKey))) {
|
||
ForEach(types) { chartType in
|
||
sidebarRow(chartType)
|
||
.tag(chartType)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.sidebar)
|
||
.frame(width: 248)
|
||
|
||
Divider()
|
||
|
||
// Right panel: KPI header + period control + chart.
|
||
// Clipped so the decorative background can't paint over the sidebar.
|
||
ZStack {
|
||
AppBackground()
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
kpiHeader
|
||
|
||
chartToolbar
|
||
|
||
if showRangeBrush && supportsGlobalRange {
|
||
globalRangeBrush
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
|
||
if !viewModel.isPremium {
|
||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||
.onAppear {
|
||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||
}
|
||
}
|
||
chartContent
|
||
if viewModel.hiddenHistoryMonths > 0 {
|
||
lockedHistoryTeaser
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.clipped()
|
||
}
|
||
.ignoresSafeArea(edges: .bottom)
|
||
// Attached here (not on the layout-switching Group): sheets presented from
|
||
// a view that gets replaced when the size class changes were unreliable in
|
||
// NavigationSplitView — premium tiles looked like they "did nothing".
|
||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||
}
|
||
|
||
// MARK: - Chart groups (sidebar sections)
|
||
|
||
private static let chartGroups: [(titleKey: String, types: [ChartsViewModel.ChartType])] = [
|
||
("chart_group_overview", [.evolution, .allocation, .performance, .contributions]),
|
||
("chart_group_analyze", [.comparison, .periodComparison, .yearOverYear, .rollingReturn]),
|
||
("chart_group_risk", [.drawdown, .volatility, .riskReturn, .cashflow]),
|
||
("chart_group_forecast", [.prediction, .simulator])
|
||
]
|
||
|
||
/// Routes List selection through selectChart so premium gating (paywall)
|
||
/// stays in one place; a rejected selection simply reverts on re-render.
|
||
private var sidebarSelection: Binding<ChartsViewModel.ChartType?> {
|
||
Binding(
|
||
get: { viewModel.selectedChartType },
|
||
set: { newValue in
|
||
guard let newValue else { return }
|
||
viewModel.selectChart(newValue)
|
||
}
|
||
)
|
||
}
|
||
|
||
private func sidebarRow(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||
HStack {
|
||
Label(chartType.rawValue, systemImage: chartType.icon)
|
||
Spacer()
|
||
if chartType.isPremium && !viewModel.isPremium {
|
||
Image(systemName: "lock.fill")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Grouped chart switcher for the title menu (iPhone) — native Picker rows
|
||
/// get the system checkmark on the active chart.
|
||
private var chartTypePicker: some View {
|
||
Picker("Chart", selection: sidebarSelection) {
|
||
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||
let types = group.types.filter { showForecast || $0 != .prediction }
|
||
if !types.isEmpty {
|
||
Section(String(localized: String.LocalizationValue(group.titleKey))) {
|
||
ForEach(types) { chartType in
|
||
Label(
|
||
chartType.rawValue,
|
||
systemImage: (chartType.isPremium && !viewModel.isPremium) ? "lock.fill" : chartType.icon
|
||
)
|
||
.tag(ChartsViewModel.ChartType?.some(chartType))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Filters (toolbar menu)
|
||
|
||
private var hasActiveFilters: Bool {
|
||
let breakdownApplies = viewModel.selectedChartType == .allocation || viewModel.selectedChartType == .performance
|
||
return viewModel.selectedCategory != nil
|
||
|| !viewModel.selectedSourceIds.isEmpty
|
||
|| (breakdownApplies && viewModel.selectedBreakdown != .category)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var filterMenu: some View {
|
||
let chartType = viewModel.selectedChartType
|
||
let categories = viewModel.availableCategories(for: chartType)
|
||
let sources = viewModel.availableSources(for: chartType)
|
||
let hasBreakdown = chartType == .allocation || chartType == .performance
|
||
if hasBreakdown || !categories.isEmpty || !sources.isEmpty {
|
||
Menu {
|
||
if hasBreakdown {
|
||
Picker("Group", selection: $viewModel.selectedBreakdown) {
|
||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||
Text(mode.rawValue).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.inline)
|
||
}
|
||
if !categories.isEmpty {
|
||
Picker("Category", selection: categorySelection) {
|
||
Text("All Categories").tag(Category?.none)
|
||
ForEach(categories) { category in
|
||
Text(category.name).tag(Optional(category))
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
if !sources.isEmpty {
|
||
Menu("Sources") {
|
||
ForEach(sources) { source in
|
||
Toggle(source.name, isOn: sourceToggle(source))
|
||
}
|
||
if !viewModel.selectedSourceIds.isEmpty {
|
||
Divider()
|
||
Button("Show All Sources") {
|
||
viewModel.selectedSource = nil
|
||
viewModel.selectedSourceIds.removeAll()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if hasActiveFilters {
|
||
Divider()
|
||
Button("Reset Filters", role: .destructive) {
|
||
viewModel.selectedCategory = nil
|
||
viewModel.selectedSource = nil
|
||
viewModel.selectedSourceIds.removeAll()
|
||
viewModel.selectedBreakdown = .category
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: hasActiveFilters
|
||
? "line.3.horizontal.decrease.circle.fill"
|
||
: "line.3.horizontal.decrease.circle")
|
||
}
|
||
.accessibilityLabel("Filters")
|
||
}
|
||
}
|
||
|
||
private var categorySelection: Binding<Category?> {
|
||
Binding(
|
||
get: { viewModel.selectedCategory },
|
||
set: { newValue in
|
||
viewModel.selectedCategory = newValue
|
||
viewModel.selectedSource = nil
|
||
}
|
||
)
|
||
}
|
||
|
||
private func sourceToggle(_ source: InvestmentSource) -> Binding<Bool> {
|
||
Binding(
|
||
get: { viewModel.selectedSourceIds.contains(source.id) },
|
||
set: { isOn in
|
||
viewModel.selectedSource = nil
|
||
if isOn {
|
||
viewModel.selectedSourceIds.insert(source.id)
|
||
} else {
|
||
viewModel.selectedSourceIds.remove(source.id)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - KPI Header (iPad)
|
||
|
||
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
|
||
/// that make sense for it (the same ones its in-card stats row shows on
|
||
/// iPhone) instead of the always-identical portfolio aggregates.
|
||
private var selectedChartLocked: Bool {
|
||
viewModel.selectedChartType.isPremium && !viewModel.isPremium
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var kpiHeader: some View {
|
||
let stats = selectedChartLocked ? [] : chartSpecificStats
|
||
if !stats.isEmpty {
|
||
HStack(spacing: 10) {
|
||
ForEach(stats.indices, id: \.self) { i in
|
||
kpiCard(label: stats[i].label, value: stats[i].value, color: stats[i].color)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var chartSpecificStats: [ChartStat] {
|
||
switch viewModel.selectedChartType {
|
||
case .evolution:
|
||
let data = viewModel.evolutionData
|
||
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 changePct = first > 0 ? ((latest - first) / first) * 100 : 0
|
||
let m = viewModel.portfolioMetrics
|
||
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: "CAGR", value: m.formattedCAGR, color: m.cagr >= 0 ? .positiveGreen : .negativeRed),
|
||
ChartStat(label: "Max", value: Decimal(values.max() ?? 0).compactCurrencyString, color: .positiveGreen),
|
||
ChartStat(label: "Min", value: Decimal(values.min() ?? 0).compactCurrencyString, color: .negativeRed),
|
||
]
|
||
case .allocation:
|
||
let data = viewModel.allocationData
|
||
guard !data.isEmpty else { return [] }
|
||
let total = data.reduce(Decimal.zero) { $0 + $1.value }
|
||
let top = data[0]
|
||
let topPct = total > 0 ? NSDecimalNumber(decimal: top.value / total * 100).doubleValue : 0
|
||
return [
|
||
ChartStat(label: "Total", value: total.compactCurrencyString, color: .primary),
|
||
ChartStat(label: "Largest", value: top.category, color: Color(hex: top.color) ?? .appPrimary),
|
||
ChartStat(label: "Top share", value: String(format: "%.1f%%", topPct), color: .appPrimary),
|
||
ChartStat(label: "Positions", value: "\(data.count)", color: .secondary),
|
||
]
|
||
case .performance:
|
||
let data = viewModel.performanceData
|
||
guard !data.isEmpty else { return [] }
|
||
let avg = data.map { $0.cagr }.reduce(0, +) / Double(data.count)
|
||
let best = data.max(by: { $0.cagr < $1.cagr })
|
||
let worst = data.min(by: { $0.cagr < $1.cagr })
|
||
return [
|
||
ChartStat(label: "Best", value: best.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .positiveGreen),
|
||
ChartStat(label: "Worst", value: worst.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .negativeRed),
|
||
ChartStat(label: "Average", value: String(format: "%+.1f%%", avg), color: .secondary),
|
||
]
|
||
case .contributions:
|
||
let data = viewModel.contributionsData
|
||
guard !data.isEmpty else { return [] }
|
||
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
|
||
let total = amounts.reduce(0, +)
|
||
return [
|
||
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
|
||
ChartStat(label: "Monthly avg", value: Decimal(total / Double(amounts.count)).compactCurrencyString, color: .secondary),
|
||
ChartStat(label: "Highest", value: Decimal(amounts.max() ?? 0).compactCurrencyString, color: .positiveGreen),
|
||
ChartStat(label: "Last", value: Decimal(amounts.last ?? 0).compactCurrencyString, color: .appPrimary),
|
||
]
|
||
case .rollingReturn:
|
||
let values = viewModel.rollingReturnData.map { $0.value }
|
||
guard !values.isEmpty else { return [] }
|
||
let latest = values.last ?? 0
|
||
let avg = values.reduce(0, +) / Double(values.count)
|
||
return [
|
||
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
|
||
ChartStat(label: "Best", value: String(format: "%.1f%%", values.max() ?? 0), color: .positiveGreen),
|
||
ChartStat(label: "Worst", value: String(format: "%.1f%%", values.min() ?? 0), color: .negativeRed),
|
||
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
||
]
|
||
case .riskReturn:
|
||
let data = viewModel.riskReturnData
|
||
guard !data.isEmpty else { return [] }
|
||
let bestReturn = data.max(by: { $0.cagr < $1.cagr })
|
||
let lowestRisk = data.min(by: { $0.volatility < $1.volatility })
|
||
return [
|
||
ChartStat(label: "Best return", value: bestReturn.map { "\($0.category) \(String(format: "%+.1f%%", $0.cagr))" } ?? "—", color: .positiveGreen),
|
||
ChartStat(label: "Lowest risk", value: lowestRisk.map { "\($0.category) \(String(format: "%.1f%%", $0.volatility))" } ?? "—", color: .appPrimary),
|
||
ChartStat(label: "Categories", value: "\(data.count)", color: .secondary),
|
||
]
|
||
case .cashflow:
|
||
let data = viewModel.cashflowData
|
||
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),
|
||
]
|
||
case .drawdown:
|
||
let values = viewModel.drawdownData.map { $0.drawdown }
|
||
guard !values.isEmpty else { return [] }
|
||
let current = values.last ?? 0
|
||
let worst = values.min() ?? 0
|
||
let avg = values.reduce(0, +) / Double(values.count)
|
||
return [
|
||
ChartStat(label: "Current", value: String(format: "%.1f%%", current), color: current >= -1 ? .positiveGreen : .negativeRed),
|
||
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
|
||
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
||
]
|
||
case .volatility:
|
||
let values = viewModel.volatilityData.map { $0.volatility }
|
||
guard !values.isEmpty else { return [] }
|
||
let avg = values.reduce(0, +) / Double(values.count)
|
||
return [
|
||
ChartStat(label: "Current", value: String(format: "%.1f%%", values.last ?? 0), color: .appPrimary),
|
||
ChartStat(label: "Max", value: String(format: "%.1f%%", values.max() ?? 0), color: .negativeRed),
|
||
ChartStat(label: "Min", value: String(format: "%.1f%%", values.min() ?? 0), color: .positiveGreen),
|
||
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
||
]
|
||
case .prediction:
|
||
guard let lastPred = viewModel.predictionData.last else { return [] }
|
||
let current = viewModel.evolutionData.last?.value ?? 0
|
||
return [
|
||
ChartStat(label: "Current", value: current.compactCurrencyString, color: .appPrimary),
|
||
ChartStat(label: "Base (\(viewModel.predictionData.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary),
|
||
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
|
||
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
|
||
]
|
||
case .comparison:
|
||
let series = viewModel.comparisonData
|
||
guard !series.isEmpty else { return [] }
|
||
// Rank sources by their latest value in the current display mode.
|
||
let ranked = series.compactMap { s -> (name: String, val: Double)? in
|
||
guard let last = s.points.last?.value else { return nil }
|
||
return (s.name, last)
|
||
}.sorted { $0.val > $1.val }
|
||
let unit = viewModel.comparisonDisplayMode == .absolute ? "" : "%"
|
||
func fmt(_ v: Double) -> String { unit.isEmpty ? Decimal(v).compactCurrencyString : String(format: "%+.1f%%", v) }
|
||
var stats: [ChartStat] = [ChartStat(label: "Series", value: "\(series.count)", color: .secondary)]
|
||
if let best = ranked.first {
|
||
stats.append(ChartStat(label: "Leader", value: "\(best.name) \(fmt(best.val))", color: .positiveGreen))
|
||
}
|
||
if ranked.count > 1, let worst = ranked.last {
|
||
stats.append(ChartStat(label: "Laggard", value: "\(worst.name) \(fmt(worst.val))", color: .negativeRed))
|
||
}
|
||
return stats
|
||
case .periodComparison:
|
||
let series = viewModel.periodComparisonData
|
||
guard !series.isEmpty else { return [] }
|
||
return series.map { s in
|
||
let end = s.points.last?.returnPct ?? 0
|
||
return ChartStat(label: s.label, value: String(format: "%+.1f%%", end),
|
||
color: end >= 0 ? .positiveGreen : .negativeRed)
|
||
}
|
||
case .yearOverYear:
|
||
let all = viewModel.yearOverYearData.sorted { $0.year < $1.year }
|
||
guard let latest = all.last else { return [] }
|
||
func endReturn(_ ys: ChartsViewModel.YearSeries) -> Double {
|
||
ys.forecastEndValue ?? (ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0)
|
||
}
|
||
let latestEnd = endReturn(latest)
|
||
var stats: [ChartStat] = [
|
||
ChartStat(label: "\(latest.year)", value: String(format: "%+.1f%%", latestEnd),
|
||
color: latestEnd >= 0 ? .positiveGreen : .negativeRed)
|
||
]
|
||
// Current year vs previous year (end-of-year return difference).
|
||
if all.count >= 2 {
|
||
let prev = all[all.count - 2]
|
||
let diff = latestEnd - endReturn(prev)
|
||
stats.append(ChartStat(
|
||
label: String(format: String(localized: "yoy_vs_prev"), "\(prev.year)"),
|
||
value: String(format: "%+.1f pp", diff),
|
||
color: diff >= 0 ? .positiveGreen : .negativeRed))
|
||
}
|
||
// Best and worst full year on record.
|
||
if all.count >= 2 {
|
||
let ranked = all.map { ($0.year, endReturn($0)) }.sorted { $0.1 > $1.1 }
|
||
if let best = ranked.first {
|
||
stats.append(ChartStat(label: String(localized: "yoy_best_year"),
|
||
value: "\(best.0) \(String(format: "%+.1f%%", best.1))",
|
||
color: .positiveGreen))
|
||
}
|
||
if let worst = ranked.last, worst.0 != ranked.first?.0 {
|
||
stats.append(ChartStat(label: String(localized: "yoy_worst_year"),
|
||
value: "\(worst.0) \(String(format: "%+.1f%%", worst.1))",
|
||
color: .negativeRed))
|
||
}
|
||
}
|
||
return stats
|
||
case .simulator:
|
||
// The simulator carries its own live sliders and comparison inline.
|
||
return []
|
||
}
|
||
}
|
||
|
||
private func kpiCard(label: String, value: String, color: Color) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
Text(value)
|
||
.font(.system(.title3, design: .rounded).weight(.semibold))
|
||
.foregroundColor(color)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.6)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
.background(Color(.systemBackground))
|
||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||
}
|
||
|
||
// MARK: - Chart toolbar (iPad): title + native segmented period picker
|
||
|
||
@ViewBuilder
|
||
private var chartToolbar: some View {
|
||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(viewModel.selectedChartType.rawValue)
|
||
.font(.headline)
|
||
Text(viewModel.selectedChartType.description)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer()
|
||
if viewModel.selectedChartType == .performance {
|
||
performancePeriodPicker
|
||
.frame(maxWidth: 360)
|
||
} else if !ranges.isEmpty {
|
||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||
ForEach(ranges) { range in
|
||
Text(range.rawValue).tag(range)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(maxWidth: 360)
|
||
}
|
||
if supportsGlobalRange {
|
||
rangeToggleButton
|
||
}
|
||
shareButton
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
/// Months of history actually available (distinct aggregated months). The
|
||
/// Performance period options never exceed this, so the control can't ask
|
||
/// for a window with no data.
|
||
private var availableHistoryMonths: Int {
|
||
max(1, viewModel.evolutionData.count)
|
||
}
|
||
|
||
/// Discrete period options (in months) capped to available data — mirrors
|
||
/// the segmented period control used by every other chart for consistency.
|
||
private var performancePeriodOptions: [(label: String, months: Int)] {
|
||
let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")]
|
||
var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) }
|
||
opts.append(("All", availableHistoryMonths)) // full history
|
||
return opts.map { (label: $0.0, months: $0.1) }
|
||
}
|
||
|
||
private var performancePeriodPicker: some View {
|
||
Picker("Period", selection: Binding(
|
||
get: {
|
||
// Snap to the closest available option.
|
||
let m = viewModel.performancePeriodMonths
|
||
return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months
|
||
?? availableHistoryMonths
|
||
},
|
||
set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) }
|
||
)) {
|
||
ForEach(performancePeriodOptions, id: \.months) { opt in
|
||
Text(opt.label).tag(opt.months)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
|
||
private var shareButton: some View {
|
||
Button {
|
||
ChartShareTip().invalidate(reason: .actionPerformed)
|
||
shareCurrentChart()
|
||
} label: {
|
||
Image(systemName: "square.and.arrow.up")
|
||
}
|
||
.popoverTip(ChartShareTip())
|
||
.disabled(!viewModel.hasData || viewModel.isLoading || selectedChartLocked)
|
||
.accessibilityLabel(String(localized: "chart_share_button"))
|
||
}
|
||
|
||
private func shareCurrentChart() {
|
||
ChartShareService.shared.share(
|
||
title: viewModel.selectedChartType.rawValue,
|
||
subtitle: viewModel.selectedChartType.description,
|
||
stats: chartSpecificStats
|
||
) {
|
||
chartContent
|
||
.environmentObject(accountStore)
|
||
.frame(width: 672)
|
||
}
|
||
}
|
||
|
||
/// 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: 16) {
|
||
chartTypeChipBar
|
||
if !viewModel.isPremium {
|
||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||
.onAppear {
|
||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||
}
|
||
}
|
||
iPhonePeriodControl
|
||
if hasActiveFilters {
|
||
activeFilterChip
|
||
}
|
||
chartContent
|
||
// Swipe left/right to move to the adjacent chart type.
|
||
// simultaneousGesture so charts with their own scrub drag
|
||
// don't swallow the swipe.
|
||
.simultaneousGesture(chartSwipeGesture)
|
||
.id(viewModel.selectedChartType)
|
||
.transition(.asymmetric(
|
||
insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity),
|
||
removal: .opacity
|
||
))
|
||
if viewModel.hiddenHistoryMonths > 0 {
|
||
lockedHistoryTeaser
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||
}
|
||
|
||
// MARK: - iPhone chart type chip bar
|
||
//
|
||
// Always-visible horizontal switcher (Health-app pattern) — replaces the
|
||
// hidden title menu as primary navigation and the broken page-dot row.
|
||
// Swipe still works and stays in sync via the auto-scroll.
|
||
|
||
private var chartTypeChipBar: some View {
|
||
ScrollViewReader { proxy in
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(orderedChartTypes) { chartType in
|
||
chartTypeChip(chartType)
|
||
.id(chartType)
|
||
}
|
||
}
|
||
.padding(.horizontal, 2)
|
||
.padding(.vertical, 2)
|
||
}
|
||
.onChange(of: viewModel.selectedChartType) { _, newValue in
|
||
withAnimation(.snappy) { proxy.scrollTo(newValue, anchor: .center) }
|
||
}
|
||
.onAppear {
|
||
proxy.scrollTo(viewModel.selectedChartType, anchor: .center)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func chartTypeChip(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||
let isSelected = viewModel.selectedChartType == chartType
|
||
let locked = chartType.isPremium && !viewModel.isPremium
|
||
return Button {
|
||
guard !isSelected else { return }
|
||
ChartHaptics.tick()
|
||
withAnimation(.snappy) { viewModel.selectChart(chartType) }
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: locked ? "lock.fill" : chartType.icon)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
Text(chartType.rawValue)
|
||
.font(.footnote.weight(isSelected ? .semibold : .regular))
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
|
||
.background(
|
||
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
|
||
)
|
||
.overlay(
|
||
Capsule().stroke(isSelected ? Color.clear : Color.gray.opacity(0.25), lineWidth: 0.8)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
/// Visible affordance for an active drill-down/filter — tap to clear.
|
||
private var activeFilterChip: some View {
|
||
let name = viewModel.selectedCategory?.name
|
||
?? viewModel.availableSources(for: viewModel.selectedChartType)
|
||
.first(where: { viewModel.selectedSourceIds.contains($0.id) })?.name
|
||
?? ""
|
||
return HStack {
|
||
Button {
|
||
withAnimation(.snappy) {
|
||
viewModel.selectedCategory = nil
|
||
viewModel.selectedSource = nil
|
||
viewModel.selectedSourceIds.removeAll()
|
||
viewModel.selectedBreakdown = .category
|
||
}
|
||
ChartHaptics.bump()
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Text(String(format: String(localized: "chart_filtered_prefix"), name))
|
||
.font(.footnote.weight(.medium))
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.footnote)
|
||
}
|
||
.foregroundColor(.appPrimary)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
// MARK: - iPhone swipe between charts
|
||
|
||
/// Chart types in the same grouped order as the title switcher menu.
|
||
private var orderedChartTypes: [ChartsViewModel.ChartType] {
|
||
Self.chartGroups.flatMap { $0.types }.filter { showForecast || $0 != .prediction }
|
||
}
|
||
|
||
@State private var swipeInsertionEdge: Edge = .trailing
|
||
|
||
private var chartSwipeGesture: some Gesture {
|
||
DragGesture(minimumDistance: 30)
|
||
.onEnded { value in
|
||
// Horizontal-dominant swipes only, so vertical scroll still works.
|
||
guard abs(value.translation.width) > abs(value.translation.height) * 1.5 else { return }
|
||
let ordered = orderedChartTypes
|
||
guard let idx = ordered.firstIndex(of: viewModel.selectedChartType) else { return }
|
||
if value.translation.width < 0, idx < ordered.count - 1 {
|
||
swipeInsertionEdge = .trailing
|
||
withAnimation(.snappy) { viewModel.selectChart(ordered[idx + 1]) }
|
||
} else if value.translation.width > 0, idx > 0 {
|
||
swipeInsertionEdge = .leading
|
||
withAnimation(.snappy) { viewModel.selectChart(ordered[idx - 1]) }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stocks-style segmented period control above the chart (the chart switcher
|
||
/// lives in the title menu, filters in the toolbar), plus the global range
|
||
/// brush toggle. The brush lives HERE — outside chartContent — so its drags
|
||
/// can't collide with the chart-swipe gesture, and its window filters the
|
||
/// data of EVERY chart (it replaces the preset as data range).
|
||
@ViewBuilder
|
||
private var iPhonePeriodControl: some View {
|
||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||
VStack(spacing: 10) {
|
||
HStack(spacing: 8) {
|
||
if viewModel.selectedChartType == .performance {
|
||
performancePeriodPicker
|
||
} else if !ranges.isEmpty {
|
||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||
ForEach(ranges) { range in
|
||
Text(range.rawValue).tag(range)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
if supportsGlobalRange {
|
||
rangeToggleButton
|
||
}
|
||
}
|
||
if showRangeBrush && supportsGlobalRange {
|
||
globalRangeBrush
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Global range brush (shared data window for all charts)
|
||
|
||
@State private var showRangeBrush = false
|
||
|
||
/// Charts whose data honors customRange (Performance/Simulator/Period vs
|
||
/// Period fetch their own windows).
|
||
private var supportsGlobalRange: Bool {
|
||
!viewModel.availableTimeRanges(for: viewModel.selectedChartType).isEmpty
|
||
&& viewModel.selectedChartType != .performance
|
||
&& viewModel.fullHistorySeries.count >= 2
|
||
}
|
||
|
||
private var rangeToggleButton: some View {
|
||
Button {
|
||
withAnimation(.snappy) { showRangeBrush.toggle() }
|
||
ChartHaptics.bump()
|
||
if showRangeBrush {
|
||
// Load the full history so any window can be selected.
|
||
if viewModel.selectedTimeRange != .all { viewModel.selectedTimeRange = .all }
|
||
} else {
|
||
viewModel.customRange = nil
|
||
}
|
||
} label: {
|
||
Image(systemName: "calendar.badge.clock")
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundColor(showRangeBrush ? .white : .appPrimary)
|
||
.frame(width: 38, height: 30)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(showRangeBrush ? Color.appPrimary : Color(.systemBackground))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color.gray.opacity(showRangeBrush ? 0 : 0.3), lineWidth: 0.8)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var globalRangeBrush: some View {
|
||
let series = viewModel.fullHistorySeries
|
||
if series.count >= 2 {
|
||
ChartRangeBrush(
|
||
dates: series.map(\.date),
|
||
values: series.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||
selection: rangeSelection
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Maps the brush's month indices to the view model's date window.
|
||
/// Full selection = no filter (nil).
|
||
private var rangeSelection: Binding<ClosedRange<Int>> {
|
||
Binding(
|
||
get: {
|
||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||
guard let last = dates.indices.last else { return 0...0 }
|
||
guard let range = viewModel.customRange else { return 0...last }
|
||
let lo = nearestIndex(in: dates, to: range.lowerBound)
|
||
let hi = nearestIndex(in: dates, to: range.upperBound)
|
||
return min(lo, hi)...max(lo, hi)
|
||
},
|
||
set: { sel in
|
||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||
guard let last = dates.indices.last else { return }
|
||
if sel.lowerBound <= 0 && sel.upperBound >= last {
|
||
viewModel.customRange = nil
|
||
} else {
|
||
viewModel.setCustomRange(
|
||
fromMonth: dates[max(0, sel.lowerBound)],
|
||
toMonth: dates[min(last, sel.upperBound)]
|
||
)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private func nearestIndex(in dates: [Date], to date: Date) -> Int {
|
||
var best = 0
|
||
var bestDist = TimeInterval.greatestFiniteMagnitude
|
||
for (i, d) in dates.enumerated() {
|
||
let dist = abs(d.timeIntervalSince(date))
|
||
if dist < bestDist { best = i; bestDist = dist }
|
||
}
|
||
return best
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
/// Drill-down: tapping a category/source in Allocation or Performance jumps
|
||
/// to Evolution filtered to it (premium). The active-filter chip is the way
|
||
/// back.
|
||
private func drillDown(to name: String) {
|
||
guard viewModel.isPremium else {
|
||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||
viewModel.showingPaywall = true
|
||
return
|
||
}
|
||
ChartHaptics.bump()
|
||
withAnimation(.snappy) {
|
||
if viewModel.selectedBreakdown == .source {
|
||
if let source = viewModel.availableSources(for: viewModel.selectedChartType)
|
||
.first(where: { $0.name == name }) {
|
||
viewModel.selectedCategory = nil
|
||
viewModel.selectedSource = nil
|
||
viewModel.selectedSourceIds = [source.id]
|
||
}
|
||
} else if let category = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||
.first(where: { $0.name == name }) {
|
||
viewModel.selectedSource = nil
|
||
viewModel.selectedSourceIds.removeAll()
|
||
viewModel.selectedCategory = category
|
||
}
|
||
viewModel.selectChart(.evolution)
|
||
}
|
||
}
|
||
|
||
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.selectedChartType.isPremium && !viewModel.isPremium {
|
||
premiumLockedView
|
||
} else 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,
|
||
contributions: viewModel.contributionsData,
|
||
isPremium: viewModel.isPremium,
|
||
onPremiumNeeded: {
|
||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||
viewModel.showingPaywall = true
|
||
}
|
||
)
|
||
case .allocation:
|
||
VStack(spacing: 20) {
|
||
AllocationPieChart(
|
||
data: viewModel.allocationData,
|
||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown),
|
||
onDrillDown: { name in drillDown(to: name) }
|
||
)
|
||
|
||
if !viewModel.allocationEvolutionData.isEmpty {
|
||
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
|
||
}
|
||
}
|
||
case .performance:
|
||
PerformanceBarChart(
|
||
data: viewModel.performanceData,
|
||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category",
|
||
onDrillDown: { name in drillDown(to: name) }
|
||
)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Shown in place of a premium chart when the user isn't premium. Selecting
|
||
/// the chart always works; unlocking is an explicit button tap (a reliable
|
||
/// context for presenting the paywall sheet).
|
||
private var premiumLockedView: some View {
|
||
VStack(spacing: 16) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.appPrimary.opacity(0.12))
|
||
.frame(width: 88, height: 88)
|
||
Image(systemName: viewModel.selectedChartType.icon)
|
||
.font(.system(size: 34))
|
||
.foregroundColor(.appPrimary)
|
||
Image(systemName: "lock.circle.fill")
|
||
.font(.system(size: 26))
|
||
.foregroundColor(.appWarning)
|
||
.background(Circle().fill(Color(.systemBackground)))
|
||
.offset(x: 32, y: 30)
|
||
}
|
||
|
||
Text(String(format: String(localized: "chart_locked_title"), viewModel.selectedChartType.rawValue))
|
||
.font(.headline)
|
||
|
||
Text(viewModel.selectedChartType.description)
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
|
||
Button {
|
||
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
|
||
viewModel.showingPaywall = true
|
||
} label: {
|
||
Text(String(localized: "chart_locked_cta"))
|
||
.font(.subheadline.weight(.semibold))
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 12)
|
||
.background(Color.appPrimary)
|
||
.foregroundColor(.white)
|
||
.cornerRadius(24)
|
||
}
|
||
}
|
||
.padding(32)
|
||
.frame(maxWidth: .infinity, minHeight: 320)
|
||
.background(Color(.systemBackground))
|
||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||
}
|
||
|
||
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: - Evolution Chart View
|
||
|
||
struct EvolutionChartView: View {
|
||
let data: [(date: Date, value: Decimal)]
|
||
@State private var zoom = ChartZoomModel()
|
||
let categoryData: [CategoryEvolutionPoint]
|
||
let goals: [Goal]
|
||
var contributions: [(date: Date, amount: Decimal)] = []
|
||
var isPremium: Bool = true
|
||
var onPremiumNeeded: (() -> Void)? = nil
|
||
@Environment(\.chartImageExport) private var chartImageExport
|
||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||
|
||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||
|
||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||
@State private var chartMode: ChartMode = .total
|
||
@State private var showGoalLines = false
|
||
@State private var showContributions = 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
|
||
if chartMode == .total && !contributions.isEmpty {
|
||
HStack {
|
||
contributionsToggle
|
||
Spacer()
|
||
}
|
||
}
|
||
chartSection
|
||
if chartMode == .total, isPremium, let insight = rangeInsight {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "lightbulb.fill")
|
||
.font(.caption2)
|
||
.foregroundColor(.appWarning)
|
||
Text(insight)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// One contextual takeaway recomputed for the visible window (premium).
|
||
private var rangeInsight: String? {
|
||
let series = visibleData
|
||
guard series.count >= 3 else { return nil }
|
||
var bestDate: Date?
|
||
var bestPct = -Double.infinity
|
||
for i in 1..<series.count {
|
||
let prev = NSDecimalNumber(decimal: series[i - 1].value).doubleValue
|
||
let cur = NSDecimalNumber(decimal: series[i].value).doubleValue
|
||
guard prev > 0 else { continue }
|
||
let pct = (cur - prev) / prev * 100
|
||
if pct > bestPct {
|
||
bestPct = pct
|
||
bestDate = series[i].date
|
||
}
|
||
}
|
||
guard let bestDate, bestPct.isFinite else { return nil }
|
||
return String(
|
||
format: String(localized: "chart_insight_best_month"),
|
||
Self.headerRangeFormatter.string(from: bestDate),
|
||
String(format: "%+.1f%%", bestPct)
|
||
)
|
||
}
|
||
|
||
/// Premium overlay: cumulative contributed capital vs market value — shows
|
||
/// at a glance what is *your money* and what is growth.
|
||
private var contributionsToggle: some View {
|
||
Button {
|
||
if isPremium {
|
||
withAnimation(.snappy) { showContributions.toggle() }
|
||
ChartHaptics.tick()
|
||
} else {
|
||
onPremiumNeeded?()
|
||
}
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
if !isPremium {
|
||
Image(systemName: "lock.fill")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
}
|
||
Text(String(localized: "chart_overlay_contributions"))
|
||
.font(.caption2.weight(.medium))
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.foregroundColor(showContributions ? .appSecondary : .secondary)
|
||
.background(
|
||
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private var cumulativeContributions: [(date: Date, value: Double)] {
|
||
var running = 0.0
|
||
return contributions
|
||
.sorted { $0.date < $1.date }
|
||
.map { item in
|
||
running += NSDecimalNumber(decimal: item.amount).doubleValue
|
||
return (item.date, running)
|
||
}
|
||
}
|
||
|
||
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),
|
||
]
|
||
}
|
||
|
||
// MARK: Live header
|
||
//
|
||
// The numbers live HERE, not on the chart: while scrubbing it shows the
|
||
// exact point (value, date, delta vs window start); at rest it summarizes
|
||
// the visible window. This is what lets the chart itself stay clean.
|
||
|
||
/// Data restricted to the currently visible window (zoom/brush).
|
||
private var visibleData: [(date: Date, value: Decimal)] {
|
||
guard let window = zoom.visibleWindow(dates: data.map(\.date)) else { return data }
|
||
let filtered = data.filter { window.contains($0.date) }
|
||
return filtered.isEmpty ? data : filtered
|
||
}
|
||
|
||
private static let headerRangeFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = .autoupdatingCurrent
|
||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||
return f
|
||
}()
|
||
|
||
private var headerView: some View {
|
||
let series = visibleData
|
||
let current = (chartMode == .total ? selectedDataPoint : nil)
|
||
?? series.last.map { (date: $0.date, value: $0.value) }
|
||
let firstValue = series.first.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||
let currentValue = current.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||
let deltaPct = firstValue > 0 ? (currentValue - firstValue) / firstValue * 100 : 0
|
||
let subtitle: String = {
|
||
if let selected = selectedDataPoint, chartMode == .total {
|
||
return selected.date.monthYearString
|
||
}
|
||
guard let first = series.first?.date, let last = series.last?.date else { return "" }
|
||
return "\(Self.headerRangeFormatter.string(from: first)) – \(Self.headerRangeFormatter.string(from: last))"
|
||
}()
|
||
|
||
return HStack(alignment: .top) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||
Text((current?.value ?? 0).compactCurrencyString)
|
||
.font(.system(.title2, design: .rounded).weight(.bold))
|
||
.contentTransition(.numericText())
|
||
if series.count > 1 {
|
||
Text(String(format: "%+.1f%%", deltaPct))
|
||
.font(.caption.weight(.bold))
|
||
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||
)
|
||
}
|
||
}
|
||
Text(subtitle)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button {
|
||
showGoalLines.toggle()
|
||
} label: {
|
||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.disabled(goals.isEmpty)
|
||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||
}
|
||
.animation(.snappy, value: selectedDataPoint?.date)
|
||
}
|
||
|
||
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))
|
||
}) {
|
||
// Haptic tick when the scrub snaps to a new point.
|
||
if selectedDataPoint?.date != closest.date {
|
||
ChartHaptics.tick()
|
||
}
|
||
selectedDataPoint = closest
|
||
}
|
||
}
|
||
.onEnded { _ in
|
||
selectedDataPoint = nil
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 300)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
// Performance: GPU rendering for smoother scrolling on older devices
|
||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||
}
|
||
|
||
@ChartContentBuilder
|
||
private var chartMarks: some ChartContent {
|
||
switch chartMode {
|
||
case .total:
|
||
// Widget-parity styling: gradient stroke, soft area fill, and NO
|
||
// permanent dots/bubbles on iPhone — the endpoint dot marks "now"
|
||
// and exact values live in the live header while scrubbing.
|
||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||
let item = pair.element
|
||
LineMark(
|
||
x: .value("Date", item.date),
|
||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||
series: .value("Series", "total")
|
||
)
|
||
.foregroundStyle(
|
||
LinearGradient(colors: [.appPrimary, .cyan],
|
||
startPoint: .leading, endPoint: .trailing)
|
||
)
|
||
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
|
||
.interpolationMethod(.catmullRom)
|
||
|
||
if !labelsCompact {
|
||
PointMark(
|
||
x: .value("Date", item.date),
|
||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||
)
|
||
.foregroundStyle(Color.appPrimary)
|
||
.symbolSize(30)
|
||
.annotation(position: .top, spacing: 3) {
|
||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
if labelsCompact, let last = data.last {
|
||
PointMark(
|
||
x: .value("Date", last.date),
|
||
y: .value("Value", NSDecimalNumber(decimal: last.value).doubleValue)
|
||
)
|
||
.foregroundStyle(Color.cyan)
|
||
.symbolSize(55)
|
||
}
|
||
|
||
if showContributions {
|
||
ForEach(cumulativeContributions, id: \.date) { item in
|
||
LineMark(
|
||
x: .value("Date", item.date),
|
||
y: .value("Contributed", item.value),
|
||
series: .value("Series", "contributions")
|
||
)
|
||
.foregroundStyle(Color.appSecondary)
|
||
.lineStyle(StrokeStyle(lineWidth: 1.6, dash: [5, 4]))
|
||
.interpolationMethod(.monotone)
|
||
}
|
||
}
|
||
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]))
|
||
}
|
||
}
|
||
|
||
// Scrub/tap selection bubble (total mode)
|
||
if chartMode == .total, let sel = selectedDataPoint {
|
||
RuleMark(x: .value("Selected", sel.date))
|
||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||
PointMark(
|
||
x: .value("Selected", sel.date),
|
||
y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue)
|
||
)
|
||
.foregroundStyle(Color.appPrimary)
|
||
.symbolSize(80)
|
||
.annotation(position: .top, spacing: 4) {
|
||
ChartValueBubble(text: sel.value.compactCurrencyString, prominent: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)]
|
||
@State private var zoom = ChartZoomModel()
|
||
|
||
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)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
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)]
|
||
@State private var zoom = ChartZoomModel()
|
||
@State private var selectedDate: Date?
|
||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||
|
||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||
private var selectedPoint: (date: Date, value: Double)? {
|
||
guard let selectedDate else { return nil }
|
||
return data.min(by: {
|
||
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
|
||
})
|
||
}
|
||
|
||
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(Array(data.enumerated()), id: \.element.date) { pair in
|
||
let item = pair.element
|
||
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)
|
||
.annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) {
|
||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||
ChartValueBubble(text: ChartLabels.percent(item.value))
|
||
}
|
||
}
|
||
}
|
||
|
||
if let sel = selectedPoint {
|
||
RuleMark(x: .value("Selected", sel.date))
|
||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||
PointMark(
|
||
x: .value("Selected", sel.date),
|
||
y: .value("Return", sel.value)
|
||
)
|
||
.foregroundStyle(Color.appPrimary)
|
||
.symbolSize(70)
|
||
.annotation(position: sel.value >= 0 ? .top : .bottom, spacing: 4) {
|
||
ChartValueBubble(text: ChartLabels.percent(sel.value), prominent: true)
|
||
}
|
||
}
|
||
}
|
||
.chartXSelection(value: $selectedDate)
|
||
.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)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
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)]
|
||
@State private var zoom = ChartZoomModel()
|
||
|
||
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)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
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)]
|
||
@Environment(\.chartImageExport) private var chartImageExport
|
||
|
||
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)
|
||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
ChartsContainerView(iapService: IAPService())
|
||
.environmentObject(AccountStore(iapService: IAPService()))
|
||
}
|