680255f69d
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab. - Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho regular, stack colapsable en compacto) con selección nativa de List. - Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos en regular, sheet en compacto). - Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:), sin ignoresSafeArea en contenido interactivo. - Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2005 lines
84 KiB
Swift
2005 lines
84 KiB
Swift
import SwiftUI
|
||
import Charts
|
||
import CoreData
|
||
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: .topBarTrailing) { accountFilterMenu }
|
||
ToolbarItem(placement: .topBarTrailing) { filterMenu }
|
||
if horizontalSizeClass != .regular {
|
||
ToolbarItem(placement: .topBarTrailing) { 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()
|
||
// Regular width has a wide canvas next to the sidebar: give
|
||
// every plot ~35% more height so the extra area shows data,
|
||
// not just a stretched iPhone chart.
|
||
.environment(\.chartHeightScale, 1.35)
|
||
}
|
||
}
|
||
.clipped()
|
||
}
|
||
// 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)
|
||
.foregroundStyle(.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)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
Text(value)
|
||
.font(.system(.title3, design: .rounded).weight(.semibold))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.6)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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)
|
||
.foregroundStyle(.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))
|
||
.foregroundStyle(Color.appPrimary)
|
||
.padding(12)
|
||
.background(Color.appPrimary.opacity(0.08))
|
||
.clipShape(RoundedRectangle(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
|
||
}
|
||
// No swipe-between-charts: it collided with every horizontal
|
||
// interaction (scrub, pan, brush). The chip bar navigates.
|
||
chartContent
|
||
.id(viewModel.selectedChartType)
|
||
.transition(.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)
|
||
.foregroundStyle(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)
|
||
}
|
||
.foregroundStyle(Color.appPrimary)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
|
||
/// 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))
|
||
.foregroundStyle(showRangeBrush ? .white : Color.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()
|
||
.chartFrame(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))
|
||
.foregroundStyle(Color.appPrimary)
|
||
Image(systemName: "lock.circle.fill")
|
||
.font(.system(size: 26))
|
||
.foregroundStyle(Color.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)
|
||
.foregroundStyle(.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)
|
||
.foregroundStyle(.white)
|
||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||
}
|
||
}
|
||
.padding(32)
|
||
.frame(maxWidth: .infinity, minHeight: 320)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("No Data Available")
|
||
.font(.headline)
|
||
|
||
Text("Add some investment sources and snapshots to see charts.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.chartFrame(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)
|
||
.foregroundStyle(Color.appWarning)
|
||
Text(insight)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if !data.isEmpty && chartMode == .total {
|
||
ChartStatsRow(stats: evolutionStats)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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)
|
||
.foregroundStyle(showContributions ? Color.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))
|
||
.foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||
)
|
||
}
|
||
}
|
||
Text(subtitle)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button {
|
||
showGoalLines.toggle()
|
||
} label: {
|
||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||
.foregroundStyle(.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")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(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: [Color.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.")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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)
|
||
.clipShape(RoundedRectangle(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(height: 260)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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.")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(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))
|
||
.clipShape(RoundedRectangle(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.")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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)
|
||
.foregroundStyle(.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(height: 260)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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.")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(height: 260)
|
||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(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))
|
||
.clipShape(RoundedRectangle(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.")
|
||
.foregroundStyle(.secondary)
|
||
.chartFrame(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartFrame(height: 260)
|
||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
ChartsContainerView(iapService: IAPService())
|
||
.environmentObject(AccountStore(iapService: IAPService()))
|
||
}
|