86c95a9e73
De 7 comentarios de TestFlight: - Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie. Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling, Drawdown, Volatility, Compare, Period, YoY, Prediction. - 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth — el estado es por el MES QUE SE VE (effective month de referenceDate), no contra la última completion global. Diff usa el snapshot de ese mes. - Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado consistente con el resto, opciones capadas a los meses de datos disponibles (availableHistoryMonths). Ya no deja pedir ventanas sin datos. - Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView. Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK → es el subset del partial-failure, no filtro ni esquema; necesita códigos internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
1593 lines
66 KiB
Swift
1593 lines
66 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 !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)
|
|
}
|
|
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: 20) {
|
|
if !viewModel.isPremium {
|
|
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
|
.onAppear {
|
|
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
|
}
|
|
}
|
|
iPhonePeriodControl
|
|
chartContent
|
|
if viewModel.hiddenHistoryMonths > 0 {
|
|
lockedHistoryTeaser
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
|
}
|
|
|
|
/// Stocks-style segmented period control above the chart (the chart switcher
|
|
/// lives in the title menu, filters in the toolbar).
|
|
@ViewBuilder
|
|
private var iPhonePeriodControl: some View {
|
|
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
)
|
|
case .allocation:
|
|
VStack(spacing: 20) {
|
|
AllocationPieChart(
|
|
data: viewModel.allocationData,
|
|
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
|
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
|
)
|
|
|
|
if !viewModel.allocationEvolutionData.isEmpty {
|
|
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
|
|
}
|
|
}
|
|
case .performance:
|
|
PerformanceBarChart(
|
|
data: viewModel.performanceData,
|
|
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
|
)
|
|
case .contributions:
|
|
ContributionsChartView(data: viewModel.contributionsData)
|
|
case .rollingReturn:
|
|
RollingReturnChartView(data: viewModel.rollingReturnData)
|
|
case .riskReturn:
|
|
RiskReturnChartView(data: viewModel.riskReturnData)
|
|
case .cashflow:
|
|
CashflowStackedChartView(data: viewModel.cashflowData)
|
|
case .drawdown:
|
|
DrawdownChart(data: viewModel.drawdownData)
|
|
case .volatility:
|
|
VolatilityChartView(data: viewModel.volatilityData)
|
|
case .prediction:
|
|
PredictionChartView(
|
|
predictions: viewModel.predictionData,
|
|
historicalData: viewModel.evolutionData
|
|
)
|
|
case .yearOverYear:
|
|
YearOverYearChartView(viewModel: viewModel)
|
|
case .comparison:
|
|
ComparisonChartView(viewModel: viewModel)
|
|
case .simulator:
|
|
AllocationSimulatorView(viewModel: viewModel)
|
|
case .periodComparison:
|
|
PeriodComparisonChartView(viewModel: viewModel)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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]
|
|
@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
|
|
|
|
enum ChartMode: String, CaseIterable, Identifiable {
|
|
case total = "Total"
|
|
case byCategory = "By Category"
|
|
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
private static let compactXAxisDateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = .autoupdatingCurrent
|
|
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
|
|
return formatter
|
|
}()
|
|
|
|
private var xAxisMonthStride: Int {
|
|
switch data.count {
|
|
case ...8:
|
|
return 1
|
|
case ...16:
|
|
return 2
|
|
case ...30:
|
|
return 3
|
|
case ...48:
|
|
return 4
|
|
default:
|
|
return 6
|
|
}
|
|
}
|
|
|
|
private var stackedCategoryData: [CategoryEvolutionPoint] {
|
|
guard !categoryData.isEmpty else { return [] }
|
|
|
|
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
|
|
result[point.categoryName, default: 0] += point.value
|
|
}
|
|
|
|
let categoryOrder = totalsByCategory
|
|
.sorted { $0.value > $1.value }
|
|
.map { $0.key }
|
|
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
|
|
|
|
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
|
|
let sortedDates = groupedByDate.keys.sorted()
|
|
|
|
var stacked: [CategoryEvolutionPoint] = []
|
|
|
|
for date in sortedDates {
|
|
guard let points = groupedByDate[date] else { continue }
|
|
let sortedPoints = points.sorted {
|
|
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
|
|
}
|
|
|
|
var running: Decimal = 0
|
|
for point in sortedPoints {
|
|
running += point.value
|
|
stacked.append(CategoryEvolutionPoint(
|
|
date: point.date,
|
|
categoryName: point.categoryName,
|
|
colorHex: point.colorHex,
|
|
value: running
|
|
))
|
|
}
|
|
}
|
|
|
|
return stacked
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
headerView
|
|
modePicker
|
|
chartSection
|
|
if !data.isEmpty && chartMode == .total {
|
|
ChartStatsRow(stats: evolutionStats)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var evolutionStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
|
let latest = values.last ?? 0
|
|
let first = values.first ?? 0
|
|
let change = latest - first
|
|
let changePct = first > 0 ? (change / first) * 100 : 0
|
|
let maxVal = values.max() ?? 0
|
|
let minVal = values.min() ?? 0
|
|
return [
|
|
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
|
|
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
|
|
ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen),
|
|
ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed),
|
|
]
|
|
}
|
|
|
|
private var headerView: some View {
|
|
HStack {
|
|
Text("Portfolio Evolution")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
if let selected = selectedDataPoint, chartMode == .total {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(selected.value.compactCurrencyString)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(selected.date.monthYearString)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
showGoalLines.toggle()
|
|
} label: {
|
|
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.disabled(goals.isEmpty)
|
|
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
|
}
|
|
}
|
|
|
|
private var modePicker: some View {
|
|
Picker("Evolution Mode", selection: $chartMode) {
|
|
ForEach(ChartMode.allCases) { mode in
|
|
Text(mode.rawValue).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var chartSection: some View {
|
|
if data.count >= 2 {
|
|
chartView
|
|
} else {
|
|
Text("Not enough data")
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 300)
|
|
}
|
|
}
|
|
|
|
private var chartView: some View {
|
|
Chart {
|
|
chartMarks
|
|
}
|
|
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
|
.chartXAxis {
|
|
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in
|
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
|
|
.foregroundStyle(Color.secondary.opacity(0.2))
|
|
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
|
|
.foregroundStyle(Color.secondary.opacity(0.28))
|
|
AxisValueLabel {
|
|
if let date = value.as(Date.self) {
|
|
Text(date, formatter: Self.compactXAxisDateFormatter)
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisValueLabel {
|
|
if let doubleValue = value.as(Double.self) {
|
|
Text(Decimal(doubleValue).shortCurrencyString)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartOverlay { proxy in
|
|
if chartMode == .total {
|
|
GeometryReader { geometry in
|
|
Rectangle()
|
|
.fill(.clear)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { value in
|
|
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
|
let plotFrame = geometry[plotFrameAnchor]
|
|
let x = value.location.x - plotFrame.origin.x
|
|
guard let date: Date = proxy.value(atX: x) else { return }
|
|
|
|
if let closest = data.min(by: {
|
|
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
|
}) {
|
|
selectedDataPoint = closest
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
selectedDataPoint = nil
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 300)
|
|
.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:
|
|
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)
|
|
)
|
|
.foregroundStyle(Color.appPrimary)
|
|
.interpolationMethod(.catmullRom)
|
|
|
|
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)
|
|
}
|
|
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()))
|
|
}
|