Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento
Charts iPad (rediseño): - Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a calculateMetrics producía KPIs absurdos) - Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección: Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad - Selector de periodo como Picker segmentado nativo en la cabecera del chart (fuera del sidebar); título + descripción del chart visibles - Eliminado sheet de paywall duplicado del layout iPad Zoom (todas las series temporales): - Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom - Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility Focus Mode eliminado (todo siempre disponible): - Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage - Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList - Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in - PeriodReturnsCard siempre visible Rendimiento y bugs (de la auditoría): - El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown - calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación lineal sobreestimaba con historiales cortos) - Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación real de las asignaciones simuladas del usuario - UITest de captura de Charts con manejo del alert de notificaciones 12 strings nuevas localizadas en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -16,7 +16,6 @@ enum ScreenshotMode {
|
|||||||
d.set(false, forKey: "pinEnabled")
|
d.set(false, forKey: "pinEnabled")
|
||||||
d.set(false, forKey: "lockOnLaunch")
|
d.set(false, forKey: "lockOnLaunch")
|
||||||
d.set(false, forKey: "lockOnBackground")
|
d.set(false, forKey: "lockOnBackground")
|
||||||
d.set(false, forKey: "calmModeEnabled") // show all charts for richer screenshots
|
|
||||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||||
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,3 +433,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ gratis";
|
"paywall_trial_format" = "%d %@ gratis";
|
||||||
|
|
||||||
"chart_history_locked" = "%d weitere Monate Verlauf — mit Premium freischalten";
|
"chart_history_locked" = "%d weitere Monate Verlauf — mit Premium freischalten";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Überblick";
|
||||||
|
"chart_group_analyze" = "Analyse";
|
||||||
|
"chart_group_risk" = "Risiko";
|
||||||
|
"chart_group_forecast" = "Prognose";
|
||||||
|
"kpi_total_value" = "Gesamtwert";
|
||||||
|
"kpi_period_return" = "Zeitraum-Rendite";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "Volatilität";
|
||||||
|
"kpi_max_drawdown" = "Max. Drawdown";
|
||||||
|
"chart_zoom_in" = "Vergrößern";
|
||||||
|
"chart_zoom_out" = "Verkleinern";
|
||||||
|
"chart_zoom_reset" = "Zoom zurücksetzen";
|
||||||
|
|||||||
@@ -441,3 +441,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ free";
|
"paywall_trial_format" = "%d %@ free";
|
||||||
|
|
||||||
"chart_history_locked" = "%d more months of history — unlock with Premium";
|
"chart_history_locked" = "%d more months of history — unlock with Premium";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Overview";
|
||||||
|
"chart_group_analyze" = "Analyze";
|
||||||
|
"chart_group_risk" = "Risk";
|
||||||
|
"chart_group_forecast" = "Forecast";
|
||||||
|
"kpi_total_value" = "Total Value";
|
||||||
|
"kpi_period_return" = "Period Return";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "Volatility";
|
||||||
|
"kpi_max_drawdown" = "Max Drawdown";
|
||||||
|
"chart_zoom_in" = "Zoom in";
|
||||||
|
"chart_zoom_out" = "Zoom out";
|
||||||
|
"chart_zoom_reset" = "Reset zoom";
|
||||||
|
|||||||
@@ -385,3 +385,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ gratis";
|
"paywall_trial_format" = "%d %@ gratis";
|
||||||
|
|
||||||
"chart_history_locked" = "%d meses más de historial — desbloquéalos con Premium";
|
"chart_history_locked" = "%d meses más de historial — desbloquéalos con Premium";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Resumen";
|
||||||
|
"chart_group_analyze" = "Análisis";
|
||||||
|
"chart_group_risk" = "Riesgo";
|
||||||
|
"chart_group_forecast" = "Previsión";
|
||||||
|
"kpi_total_value" = "Valor total";
|
||||||
|
"kpi_period_return" = "Retorno periodo";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "Volatilidad";
|
||||||
|
"kpi_max_drawdown" = "Caída máx.";
|
||||||
|
"chart_zoom_in" = "Acercar";
|
||||||
|
"chart_zoom_out" = "Alejar";
|
||||||
|
"chart_zoom_reset" = "Restablecer zoom";
|
||||||
|
|||||||
@@ -433,3 +433,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ gratuits";
|
"paywall_trial_format" = "%d %@ gratuits";
|
||||||
|
|
||||||
"chart_history_locked" = "%d mois d'historique en plus — débloquez avec Premium";
|
"chart_history_locked" = "%d mois d'historique en plus — débloquez avec Premium";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Aperçu";
|
||||||
|
"chart_group_analyze" = "Analyse";
|
||||||
|
"chart_group_risk" = "Risque";
|
||||||
|
"chart_group_forecast" = "Prévision";
|
||||||
|
"kpi_total_value" = "Valeur totale";
|
||||||
|
"kpi_period_return" = "Rendement période";
|
||||||
|
"kpi_cagr" = "TCAC";
|
||||||
|
"kpi_volatility" = "Volatilité";
|
||||||
|
"kpi_max_drawdown" = "Baisse max.";
|
||||||
|
"chart_zoom_in" = "Zoom avant";
|
||||||
|
"chart_zoom_out" = "Zoom arrière";
|
||||||
|
"chart_zoom_reset" = "Réinitialiser le zoom";
|
||||||
|
|||||||
@@ -433,3 +433,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ gratis";
|
"paywall_trial_format" = "%d %@ gratis";
|
||||||
|
|
||||||
"chart_history_locked" = "%d mesi di storico in più — sblocca con Premium";
|
"chart_history_locked" = "%d mesi di storico in più — sblocca con Premium";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Panoramica";
|
||||||
|
"chart_group_analyze" = "Analisi";
|
||||||
|
"chart_group_risk" = "Rischio";
|
||||||
|
"chart_group_forecast" = "Previsione";
|
||||||
|
"kpi_total_value" = "Valore totale";
|
||||||
|
"kpi_period_return" = "Rendimento periodo";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "Volatilità";
|
||||||
|
"kpi_max_drawdown" = "Drawdown max";
|
||||||
|
"chart_zoom_in" = "Ingrandisci";
|
||||||
|
"chart_zoom_out" = "Riduci";
|
||||||
|
"chart_zoom_reset" = "Reimposta zoom";
|
||||||
|
|||||||
@@ -433,3 +433,16 @@
|
|||||||
"paywall_trial_format" = "%d%@無料";
|
"paywall_trial_format" = "%d%@無料";
|
||||||
|
|
||||||
"chart_history_locked" = "さらに%dか月分の履歴 — プレミアムで解除";
|
"chart_history_locked" = "さらに%dか月分の履歴 — プレミアムで解除";
|
||||||
|
|
||||||
|
"chart_group_overview" = "概要";
|
||||||
|
"chart_group_analyze" = "分析";
|
||||||
|
"chart_group_risk" = "リスク";
|
||||||
|
"chart_group_forecast" = "予測";
|
||||||
|
"kpi_total_value" = "合計金額";
|
||||||
|
"kpi_period_return" = "期間リターン";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "ボラティリティ";
|
||||||
|
"kpi_max_drawdown" = "最大下落率";
|
||||||
|
"chart_zoom_in" = "拡大";
|
||||||
|
"chart_zoom_out" = "縮小";
|
||||||
|
"chart_zoom_reset" = "ズームをリセット";
|
||||||
|
|||||||
@@ -433,3 +433,16 @@
|
|||||||
"paywall_trial_format" = "%d %@ grátis";
|
"paywall_trial_format" = "%d %@ grátis";
|
||||||
|
|
||||||
"chart_history_locked" = "%d meses a mais de histórico — desbloqueie com o Premium";
|
"chart_history_locked" = "%d meses a mais de histórico — desbloqueie com o Premium";
|
||||||
|
|
||||||
|
"chart_group_overview" = "Visão geral";
|
||||||
|
"chart_group_analyze" = "Análise";
|
||||||
|
"chart_group_risk" = "Risco";
|
||||||
|
"chart_group_forecast" = "Previsão";
|
||||||
|
"kpi_total_value" = "Valor total";
|
||||||
|
"kpi_period_return" = "Retorno período";
|
||||||
|
"kpi_cagr" = "CAGR";
|
||||||
|
"kpi_volatility" = "Volatilidade";
|
||||||
|
"kpi_max_drawdown" = "Queda máx.";
|
||||||
|
"chart_zoom_in" = "Ampliar";
|
||||||
|
"chart_zoom_out" = "Reduzir";
|
||||||
|
"chart_zoom_reset" = "Redefinir zoom";
|
||||||
|
|||||||
@@ -87,16 +87,6 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
|
|
||||||
let types: [ChartType] = calmModeEnabled
|
|
||||||
? [.evolution, .allocation, .performance, .contributions, .comparison, .periodComparison]
|
|
||||||
: ChartType.allCases
|
|
||||||
if !types.contains(selectedChartType) {
|
|
||||||
selectedChartType = .evolution
|
|
||||||
}
|
|
||||||
return types
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published var selectedChartType: ChartType = .evolution
|
@Published var selectedChartType: ChartType = .evolution
|
||||||
@@ -124,6 +114,8 @@ class ChartsViewModel: ObservableObject {
|
|||||||
/// Months of data hidden by the free-tier 12-month history limit (0 for premium).
|
/// Months of data hidden by the free-tier 12-month history limit (0 for premium).
|
||||||
/// Drives the "unlock X more months" teaser in the charts screen.
|
/// Drives the "unlock X more months" teaser in the charts screen.
|
||||||
@Published var hiddenHistoryMonths: Int = 0
|
@Published var hiddenHistoryMonths: Int = 0
|
||||||
|
/// Portfolio-level metrics for the active time range — feeds the iPad KPI header.
|
||||||
|
@Published var portfolioMetrics: InvestmentMetrics = .empty
|
||||||
@Published var yoySelectedYears: Set<Int> = []
|
@Published var yoySelectedYears: Set<Int> = []
|
||||||
|
|
||||||
struct YearSeries: Identifiable {
|
struct YearSeries: Identifiable {
|
||||||
@@ -311,6 +303,13 @@ class ChartsViewModel: ObservableObject {
|
|||||||
self.lastBreakdown != selectedBreakdown
|
self.lastBreakdown != selectedBreakdown
|
||||||
|
|
||||||
if hasChanges {
|
if hasChanges {
|
||||||
|
// Raw snapshots only depend on the data selection (sources/account/
|
||||||
|
// category) — switching chart type, range or breakdown reuses them.
|
||||||
|
let dataSelectionChanged = self.lastCategoryId != category?.id ||
|
||||||
|
self.lastSourceId != selectedSource?.id ||
|
||||||
|
self.lastSourceIds != sourceIds ||
|
||||||
|
self.lastAccountId != safeSelectedAccountId ||
|
||||||
|
self.lastShowAllAccounts != showAll
|
||||||
self.lastChartType = chartType
|
self.lastChartType = chartType
|
||||||
self.lastTimeRange = timeRange
|
self.lastTimeRange = timeRange
|
||||||
self.lastCategoryId = category?.id
|
self.lastCategoryId = category?.id
|
||||||
@@ -319,7 +318,9 @@ class ChartsViewModel: ObservableObject {
|
|||||||
self.lastAccountId = safeSelectedAccountId
|
self.lastAccountId = safeSelectedAccountId
|
||||||
self.lastShowAllAccounts = showAll
|
self.lastShowAllAccounts = showAll
|
||||||
self.lastBreakdown = selectedBreakdown
|
self.lastBreakdown = selectedBreakdown
|
||||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
if dataSelectionChanged {
|
||||||
|
self.cachedSnapshots = nil
|
||||||
|
}
|
||||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +469,9 @@ class ChartsViewModel: ObservableObject {
|
|||||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portfolio KPIs for the visible range (iPad header)
|
||||||
|
portfolioMetrics = computePortfolioKPIs(from: snapshots)
|
||||||
|
|
||||||
let completedSnapshots = filterSnapshotsForCharts(
|
let completedSnapshots = filterSnapshotsForCharts(
|
||||||
sources: sources,
|
sources: sources,
|
||||||
snapshots: snapshots
|
snapshots: snapshots
|
||||||
@@ -1072,7 +1076,11 @@ class ChartsViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var peak = totals.first!.totalValue
|
guard let firstTotal = totals.first else {
|
||||||
|
drawdownData = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var peak = firstTotal.totalValue
|
||||||
var data: [(date: Date, drawdown: Double)] = []
|
var data: [(date: Date, drawdown: Double)] = []
|
||||||
|
|
||||||
for point in totals {
|
for point in totals {
|
||||||
@@ -1378,12 +1386,16 @@ class ChartsViewModel: ObservableObject {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if sources changed (avoid resetting sliders on normal refresh)
|
// Only reset sliders when the source set actually changed; otherwise refresh
|
||||||
|
// current percentages but keep the user's simulated allocations.
|
||||||
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
|
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
|
||||||
simulatorSources = simulatorNew
|
simulatorSources = simulatorNew
|
||||||
} else {
|
} else {
|
||||||
// Update currentPct but preserve simulatedPct
|
simulatorSources = zip(simulatorSources, simulatorNew).map { old, new in
|
||||||
simulatorSources = simulatorNew
|
var updated = new
|
||||||
|
updated.simulatedPct = old.simulatedPct
|
||||||
|
return updated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
||||||
@@ -1540,6 +1552,65 @@ class ChartsViewModel: ObservableObject {
|
|||||||
periodComparisonData = result
|
periodComparisonData = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Portfolio-level KPIs computed over the AGGREGATED monthly totals.
|
||||||
|
/// Feeding raw multi-source snapshots into CalculationService.calculateMetrics
|
||||||
|
/// produced nonsense (first/last belong to different sources) — the aggregate
|
||||||
|
/// series is the correct basis for portfolio return, CAGR, volatility and drawdown.
|
||||||
|
private func computePortfolioKPIs(from snapshots: [Snapshot]) -> InvestmentMetrics {
|
||||||
|
let totals = monthlyTotals(from: snapshots)
|
||||||
|
guard totals.count >= 2,
|
||||||
|
let first = totals.first, let last = totals.last,
|
||||||
|
first.totalValue > 0 else {
|
||||||
|
let value = totals.last?.totalValue ?? 0
|
||||||
|
return InvestmentMetrics(
|
||||||
|
totalValue: value, totalContributions: 0,
|
||||||
|
absoluteReturn: 0, percentageReturn: 0,
|
||||||
|
cagr: 0, twr: 0, volatility: 0, maxDrawdown: 0, sharpeRatio: 0,
|
||||||
|
bestMonth: nil, worstMonth: nil, winRate: 0, averageMonthlyReturn: 0,
|
||||||
|
startDate: totals.first?.date, endDate: totals.last?.date,
|
||||||
|
totalMonths: totals.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let absolute = last.totalValue - first.totalValue
|
||||||
|
let percentage = (absolute / first.totalValue) * 100
|
||||||
|
let cagr = calculationService.calculateCAGR(
|
||||||
|
startValue: first.totalValue, endValue: last.totalValue,
|
||||||
|
startDate: first.date, endDate: last.date
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monthly returns over the aggregate series
|
||||||
|
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||||
|
for i in 1..<totals.count where totals[i - 1].totalValue > 0 {
|
||||||
|
let r = NSDecimalNumber(
|
||||||
|
decimal: (totals[i].totalValue - totals[i - 1].totalValue) / totals[i - 1].totalValue
|
||||||
|
).doubleValue * 100
|
||||||
|
monthlyReturns.append(.init(date: totals[i].date, returnPercentage: r))
|
||||||
|
}
|
||||||
|
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||||
|
let maxDrawdown = calculationService.calculateMaxDrawdown(values: totals.map { $0.totalValue })
|
||||||
|
let avgMonthly = monthlyReturns.isEmpty
|
||||||
|
? 0 : monthlyReturns.map { $0.returnPercentage }.reduce(0, +) / Double(monthlyReturns.count)
|
||||||
|
let sharpe = calculationService.calculateSharpeRatio(
|
||||||
|
averageReturn: avgMonthly * 12, volatility: volatility, riskFreeRate: 2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return InvestmentMetrics(
|
||||||
|
totalValue: last.totalValue,
|
||||||
|
totalContributions: 0,
|
||||||
|
absoluteReturn: absolute,
|
||||||
|
percentageReturn: percentage,
|
||||||
|
cagr: cagr, twr: 0,
|
||||||
|
volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpe,
|
||||||
|
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||||
|
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||||
|
winRate: calculationService.calculateWinRate(monthlyReturns: monthlyReturns),
|
||||||
|
averageMonthlyReturn: avgMonthly,
|
||||||
|
startDate: first.date, endDate: last.date,
|
||||||
|
totalMonths: totals.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||||
chartMonthStart(for: snapshot.date)
|
chartMonthStart(for: snapshot.date)
|
||||||
|
|||||||
@@ -494,8 +494,19 @@ class DashboardViewModel: ObservableObject {
|
|||||||
let totalGrowth = lastPoint.value - firstPoint.value
|
let totalGrowth = lastPoint.value - firstPoint.value
|
||||||
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
|
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
|
||||||
|
|
||||||
// Project 12 months ahead
|
// Project 12 months ahead using compound growth (matches CAGR semantics);
|
||||||
let forecastValue = currentValue + (monthlyGrowth * 12)
|
// fall back to the linear projection if the ratio is degenerate.
|
||||||
|
let firstD = NSDecimalNumber(decimal: firstPoint.value).doubleValue
|
||||||
|
let lastD = NSDecimalNumber(decimal: lastPoint.value).doubleValue
|
||||||
|
let currentD = NSDecimalNumber(decimal: currentValue).doubleValue
|
||||||
|
let forecastValue: Decimal
|
||||||
|
if firstD > 0, lastD > 0 {
|
||||||
|
let monthlyRate = pow(lastD / firstD, 1.0 / Double(monthsBetween))
|
||||||
|
let projected = currentD * pow(monthlyRate, 12)
|
||||||
|
forecastValue = projected.isFinite ? Decimal(projected) : currentValue + (monthlyGrowth * 12)
|
||||||
|
} else {
|
||||||
|
forecastValue = currentValue + (monthlyGrowth * 12)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate confidence interval based on volatility
|
// Calculate confidence interval based on volatility
|
||||||
let volatility = calculatePortfolioVolatility(from: values)
|
let volatility = calculatePortfolioVolatility(from: values)
|
||||||
@@ -537,10 +548,13 @@ class DashboardViewModel: ObservableObject {
|
|||||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||||
let totalReturn = (last.value - first.value) / first.value
|
let totalReturn = (last.value - first.value) / first.value
|
||||||
|
|
||||||
// Annualize: (1 + total_return)^(12/months) - 1
|
// Annualize compounding: (1 + total_return)^(12/months) - 1.
|
||||||
let monthlyReturn = totalReturn / Decimal(monthsBetween)
|
// The previous linear approximation (monthly*12) overstated short histories.
|
||||||
let annualized = monthlyReturn * 12
|
let totalReturnD = NSDecimalNumber(decimal: totalReturn).doubleValue
|
||||||
return annualized
|
guard totalReturnD > -1 else { return 0 }
|
||||||
|
let annualizedD = pow(1 + totalReturnD, 12.0 / Double(monthsBetween)) - 1
|
||||||
|
guard annualizedD.isFinite else { return 0 }
|
||||||
|
return Decimal(annualizedD)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
|
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
// MARK: - Time-series chart zoom
|
||||||
|
//
|
||||||
|
// Shared zoom behavior for every chart with a Date X axis. Default state is
|
||||||
|
// "fit all" (no behavior change); zooming in narrows the visible X domain and
|
||||||
|
// enables horizontal scrolling so the user can pan through history. Zoom is
|
||||||
|
// driven by pinch (magnification) and by explicit +/- buttons for
|
||||||
|
// discoverability and accessibility (HIG: never make gestures the only way).
|
||||||
|
|
||||||
|
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
|
||||||
|
struct ChartZoomModel {
|
||||||
|
/// Currently visible X-domain length in seconds; nil = show everything.
|
||||||
|
var visibleSpan: TimeInterval?
|
||||||
|
/// Span captured when a pinch gesture begins.
|
||||||
|
var pinchAnchor: TimeInterval?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
visibleSpan = nil
|
||||||
|
pinchAnchor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||||||
|
private static let zoomStep = 0.6 // per button tap
|
||||||
|
|
||||||
|
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
|
||||||
|
let upper = max(fullSpan, Self.minSpan)
|
||||||
|
let clamped = min(max(span, Self.minSpan), upper)
|
||||||
|
// Snap back to fit-all when zoomed (almost) fully out.
|
||||||
|
return clamped >= upper * 0.98 ? nil : clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func zoomIn(fullSpan: TimeInterval) {
|
||||||
|
let current = visibleSpan ?? fullSpan
|
||||||
|
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func zoomOut(fullSpan: TimeInterval) {
|
||||||
|
guard let current = visibleSpan else { return }
|
||||||
|
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
visibleSpan = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Applies zoomable behavior to a Chart with a Date X axis.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - dates: the full set of X values (used to compute the total span)
|
||||||
|
/// - zoom: binding to the chart's zoom state
|
||||||
|
@ViewBuilder
|
||||||
|
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||||||
|
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
|
||||||
|
self
|
||||||
|
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
|
||||||
|
.simultaneousGesture(
|
||||||
|
MagnifyGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
var model = zoom.wrappedValue
|
||||||
|
if model.pinchAnchor == nil {
|
||||||
|
model.pinchAnchor = model.visibleSpan ?? fullSpan
|
||||||
|
}
|
||||||
|
if let anchor = model.pinchAnchor, value.magnification > 0 {
|
||||||
|
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
|
||||||
|
}
|
||||||
|
zoom.wrappedValue = model
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
zoom.wrappedValue.pinchAnchor = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChartZoomHelper {
|
||||||
|
static func fullSpan(of dates: [Date]) -> TimeInterval {
|
||||||
|
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||||||
|
return ChartZoomModel.minSpan
|
||||||
|
}
|
||||||
|
return max.timeIntervalSince(min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ZoomDomainModifier: ViewModifier {
|
||||||
|
@Binding var zoom: ChartZoomModel
|
||||||
|
let fullSpan: TimeInterval
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if let span = zoom.visibleSpan {
|
||||||
|
content
|
||||||
|
.chartScrollableAxes(.horizontal)
|
||||||
|
.chartXVisibleDomain(length: span)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
|
||||||
|
struct ChartZoomControls: View {
|
||||||
|
@Binding var zoom: ChartZoomModel
|
||||||
|
let fullSpan: TimeInterval
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "chart_zoom_out"))
|
||||||
|
|
||||||
|
Divider().frame(height: 16)
|
||||||
|
|
||||||
|
controlButton(systemName: "plus.magnifyingglass",
|
||||||
|
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "chart_zoom_in"))
|
||||||
|
|
||||||
|
if zoom.visibleSpan != nil {
|
||||||
|
Divider().frame(height: 16)
|
||||||
|
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "chart_zoom_reset"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
|
||||||
|
.frame(width: 32, height: 28)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ struct ChartsContainerView: View {
|
|||||||
@EnvironmentObject var accountStore: AccountStore
|
@EnvironmentObject var accountStore: AccountStore
|
||||||
@StateObject private var viewModel: ChartsViewModel
|
@StateObject private var viewModel: ChartsViewModel
|
||||||
@StateObject private var goalsViewModel = GoalsViewModel()
|
@StateObject private var goalsViewModel = GoalsViewModel()
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
@AppStorage("showForecast") private var showForecast = true
|
@AppStorage("showForecast") private var showForecast = true
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ struct ChartsContainerView: View {
|
|||||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||||||
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
|
|
||||||
}
|
}
|
||||||
.onAppear { syncState() }
|
.onAppear { syncState() }
|
||||||
.onChange(of: accountStore.selectedAccount) { _, newAccount in
|
.onChange(of: accountStore.selectedAccount) { _, newAccount in
|
||||||
@@ -47,93 +45,56 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Focus Mode Button
|
|
||||||
|
|
||||||
private var focusModeButton: some View {
|
|
||||||
Button {
|
|
||||||
calmModeEnabled.toggle()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
||||||
Text(calmModeEnabled ? "Focus" : "Full")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
|
|
||||||
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
||||||
.cornerRadius(14)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - iPad Layout (sidebar + full-width chart)
|
// MARK: - iPad Layout (sidebar + full-width chart)
|
||||||
|
|
||||||
private var iPadChartsLayout: some View {
|
private var iPadChartsLayout: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Left sidebar: chart type list + filters
|
// Left sidebar: grouped chart-type tiles + contextual filters
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("Chart Type")
|
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||||||
.font(.caption.weight(.semibold))
|
let types = group.types.filter { showForecast || $0 != .prediction }
|
||||||
.foregroundColor(.secondary)
|
if !types.isEmpty {
|
||||||
.padding(.horizontal, 16)
|
Text(String(localized: String.LocalizationValue(group.titleKey)))
|
||||||
.padding(.top, 16)
|
.font(.caption.weight(.semibold))
|
||||||
.padding(.bottom, 8)
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) {
|
||||||
.filter { showForecast || $0 != .prediction }
|
ForEach(types) { chartType in
|
||||||
ForEach(types) { chartType in
|
iPadChartTypeTile(chartType)
|
||||||
iPadChartTypeRow(chartType)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Divider().padding(.vertical, 8)
|
|
||||||
|
|
||||||
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
|
|
||||||
Button {
|
|
||||||
calmModeEnabled.toggle()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(calmModeEnabled ? .white : .primary)
|
|
||||||
}
|
}
|
||||||
Text("Focus Mode")
|
.padding(.horizontal, 12)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
if hasAnyFilter {
|
if hasAnyFilter {
|
||||||
Divider().padding(.vertical, 12)
|
Divider().padding(.vertical, 12)
|
||||||
filtersSection
|
filtersSection
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
|
} else {
|
||||||
|
Spacer().frame(height: 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 220)
|
.frame(width: 248)
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Right panel: full-width chart
|
// Right panel: KPI header + period control + chart
|
||||||
ZStack {
|
ZStack {
|
||||||
AppBackground()
|
AppBackground()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 16) {
|
||||||
|
kpiHeader
|
||||||
|
|
||||||
|
chartToolbar
|
||||||
|
|
||||||
if !viewModel.isPremium {
|
if !viewModel.isPremium {
|
||||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -150,7 +111,79 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
.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])
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - KPI Header (iPad)
|
||||||
|
|
||||||
|
/// Portfolio-level metrics for the active time range, always visible above the chart.
|
||||||
|
private var kpiHeader: some View {
|
||||||
|
let m = viewModel.portfolioMetrics
|
||||||
|
return HStack(spacing: 10) {
|
||||||
|
kpiCard(label: String(localized: "kpi_total_value"), value: m.formattedTotalValue, color: .primary)
|
||||||
|
kpiCard(label: String(localized: "kpi_period_return"), value: m.formattedPercentageReturn,
|
||||||
|
color: m.percentageReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||||
|
kpiCard(label: String(localized: "kpi_cagr"), value: m.formattedCAGR,
|
||||||
|
color: m.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||||
|
kpiCard(label: String(localized: "kpi_volatility"), value: m.formattedVolatility, color: .primary)
|
||||||
|
kpiCard(label: String(localized: "kpi_max_drawdown"), value: m.formattedMaxDrawdown, color: .negativeRed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !ranges.isEmpty && viewModel.selectedChartType != .performance {
|
||||||
|
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||||
|
ForEach(ranges) { range in
|
||||||
|
Text(range.rawValue).tag(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concrete loss-framing upsell: the user has real data older than the free
|
/// Concrete loss-framing upsell: the user has real data older than the free
|
||||||
@@ -202,38 +235,44 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iPad Chart Type Row
|
// MARK: - iPad Chart Type Tile
|
||||||
|
|
||||||
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
|
private func iPadChartTypeTile(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||||||
let isSelected = viewModel.selectedChartType == chartType
|
let isSelected = viewModel.selectedChartType == chartType
|
||||||
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
|
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
|
||||||
return Button {
|
return Button {
|
||||||
viewModel.selectChart(chartType)
|
viewModel.selectChart(chartType)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
VStack(spacing: 6) {
|
||||||
ZStack {
|
ZStack(alignment: .topTrailing) {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
Image(systemName: chartType.icon)
|
Image(systemName: chartType.icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 18))
|
||||||
.foregroundColor(isSelected ? .white : .primary)
|
.foregroundColor(isSelected ? .white : .appPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 34)
|
||||||
|
if isPremiumLocked {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(chartType.rawValue)
|
Text(chartType.rawValue)
|
||||||
.font(.subheadline)
|
.font(.caption2.weight(isSelected ? .semibold : .regular))
|
||||||
.foregroundColor(isPremiumLocked ? .secondary : .primary)
|
.foregroundColor(isSelected ? .white : .primary)
|
||||||
Spacer()
|
.lineLimit(2)
|
||||||
if isPremiumLocked {
|
.multilineTextAlignment(.center)
|
||||||
Image(systemName: "lock.fill")
|
.minimumScaleFactor(0.8)
|
||||||
.font(.caption2)
|
.frame(height: 26, alignment: .top)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.vertical, 8)
|
||||||
.padding(.vertical, 10)
|
.padding(.horizontal, 4)
|
||||||
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isSelected ? Color.appPrimary : Color(.systemGray6))
|
||||||
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(chartType.rawValue)
|
||||||
|
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared
|
// MARK: - Shared
|
||||||
@@ -253,7 +292,7 @@ struct ChartsContainerView: View {
|
|||||||
private var chartTypeSelector: some View {
|
private var chartTypeSelector: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in
|
ForEach(viewModel.availableChartTypes.filter { showForecast || $0 != .prediction }) { chartType in
|
||||||
ChartTypeButton(
|
ChartTypeButton(
|
||||||
chartType: chartType,
|
chartType: chartType,
|
||||||
isSelected: viewModel.selectedChartType == chartType,
|
isSelected: viewModel.selectedChartType == chartType,
|
||||||
@@ -272,11 +311,13 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
private var hasAnyFilter: Bool {
|
private var hasAnyFilter: Bool {
|
||||||
let chartType = viewModel.selectedChartType
|
let chartType = viewModel.selectedChartType
|
||||||
|
let isPad = horizontalSizeClass == .regular
|
||||||
// These chart types manage their own controls internally, no external filter bar needed
|
// These chart types manage their own controls internally, no external filter bar needed
|
||||||
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
|
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
|
||||||
return !viewModel.availableTimeRanges(for: chartType).isEmpty
|
// On iPad the period picker lives in the chart toolbar, not the filter bar.
|
||||||
|
return isPad ? false : !viewModel.availableTimeRanges(for: chartType).isEmpty
|
||||||
}
|
}
|
||||||
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
|
let hasTimeRange = !isPad && chartType != .allocation && chartType != .riskReturn
|
||||||
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
||||||
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
||||||
let hasBreakdown = chartType == .allocation || chartType == .performance
|
let hasBreakdown = chartType == .allocation || chartType == .performance
|
||||||
@@ -289,8 +330,10 @@ struct ChartsContainerView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
let chartType = viewModel.selectedChartType
|
let chartType = viewModel.selectedChartType
|
||||||
|
|
||||||
// Time Range (not for performance — uses slider)
|
// Time Range (not for performance — uses slider). On iPad the period
|
||||||
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
|
// picker lives in the chart toolbar, not the sidebar.
|
||||||
|
if horizontalSizeClass != .regular
|
||||||
|
&& chartType != .allocation && chartType != .riskReturn && chartType != .performance {
|
||||||
filterRow(icon: "calendar", label: "Period") {
|
filterRow(icon: "calendar", label: "Period") {
|
||||||
timeRangeSelector
|
timeRangeSelector
|
||||||
}
|
}
|
||||||
@@ -722,6 +765,7 @@ struct ChartTypeButton: View {
|
|||||||
|
|
||||||
struct EvolutionChartView: View {
|
struct EvolutionChartView: View {
|
||||||
let data: [(date: Date, value: Decimal)]
|
let data: [(date: Date, value: Decimal)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
let categoryData: [CategoryEvolutionPoint]
|
let categoryData: [CategoryEvolutionPoint]
|
||||||
let goals: [Goal]
|
let goals: [Goal]
|
||||||
|
|
||||||
@@ -933,6 +977,7 @@ struct EvolutionChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 300)
|
.frame(height: 300)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
// Performance: Use GPU rendering for smoother scrolling on older devices
|
// Performance: Use GPU rendering for smoother scrolling on older devices
|
||||||
.drawingGroup()
|
.drawingGroup()
|
||||||
}
|
}
|
||||||
@@ -1025,6 +1070,7 @@ struct EvolutionChartView: View {
|
|||||||
|
|
||||||
struct ContributionsChartView: View {
|
struct ContributionsChartView: View {
|
||||||
let data: [(date: Date, amount: Decimal)]
|
let data: [(date: Date, amount: Decimal)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -1062,6 +1108,7 @@ struct ContributionsChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1091,6 +1138,7 @@ struct ContributionsChartView: View {
|
|||||||
|
|
||||||
struct RollingReturnChartView: View {
|
struct RollingReturnChartView: View {
|
||||||
let data: [(date: Date, value: Double)]
|
let data: [(date: Date, value: Double)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -1135,6 +1183,7 @@ struct RollingReturnChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
||||||
ChartDataTable(
|
ChartDataTable(
|
||||||
rows: rollingTableRows,
|
rows: rollingTableRows,
|
||||||
@@ -1250,6 +1299,7 @@ struct RiskReturnChartView: View {
|
|||||||
|
|
||||||
struct CashflowStackedChartView: View {
|
struct CashflowStackedChartView: View {
|
||||||
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
|
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -1298,6 +1348,7 @@ struct CashflowStackedChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Charts
|
|||||||
|
|
||||||
struct DrawdownChart: View {
|
struct DrawdownChart: View {
|
||||||
let data: [(date: Date, drawdown: Double)]
|
let data: [(date: Date, drawdown: Double)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
|
|
||||||
var maxDrawdown: Double {
|
var maxDrawdown: Double {
|
||||||
abs(data.map { $0.drawdown }.min() ?? 0)
|
abs(data.map { $0.drawdown }.min() ?? 0)
|
||||||
@@ -74,6 +75,7 @@ struct DrawdownChart: View {
|
|||||||
}
|
}
|
||||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||||
.frame(height: 250)
|
.frame(height: 250)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
@@ -164,6 +166,7 @@ struct DrawdownStatView: View {
|
|||||||
|
|
||||||
struct VolatilityChartView: View {
|
struct VolatilityChartView: View {
|
||||||
let data: [(date: Date, volatility: Double)]
|
let data: [(date: Date, volatility: Double)]
|
||||||
|
@State private var zoom = ChartZoomModel()
|
||||||
|
|
||||||
var currentVolatility: Double {
|
var currentVolatility: Double {
|
||||||
data.last?.volatility ?? 0
|
data.last?.volatility ?? 0
|
||||||
@@ -236,6 +239,7 @@ struct VolatilityChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 250)
|
.frame(height: 250)
|
||||||
|
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||||
|
|
||||||
ChartStatsRow(stats: [
|
ChartStatsRow(stats: [
|
||||||
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
|
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct DashboardView: View {
|
|||||||
@State private var showingAddSource = false
|
@State private var showingAddSource = false
|
||||||
@State private var showingCustomize = false
|
@State private var showingCustomize = false
|
||||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
@AppStorage("showForecast") private var showForecast = true
|
@AppStorage("showForecast") private var showForecast = true
|
||||||
@State private var pendingAlertDismissed = false
|
@State private var pendingAlertDismissed = false
|
||||||
@State private var fullScreenSection: DashboardSection? = nil
|
@State private var fullScreenSection: DashboardSection? = nil
|
||||||
@@ -103,9 +102,6 @@ struct DashboardView: View {
|
|||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: "slider.horizontal.3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
dashboardFocusModeButton
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.selectedAccount = accountStore.selectedAccount
|
viewModel.selectedAccount = accountStore.selectedAccount
|
||||||
@@ -150,24 +146,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dashboardFocusModeButton: some View {
|
|
||||||
Button {
|
|
||||||
calmModeEnabled.toggle()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
||||||
Text(calmModeEnabled ? "Focus" : "Full")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
|
|
||||||
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
||||||
.cornerRadius(14)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var streakBadge: some View {
|
private var streakBadge: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
@@ -354,13 +332,9 @@ struct DashboardView: View {
|
|||||||
} else {
|
} else {
|
||||||
TotalValueCard(
|
TotalValueCard(
|
||||||
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||||
changeText: calmModeEnabled
|
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
|
||||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
changeLabel: "since last check-in",
|
||||||
: viewModel.portfolioSummary.formattedDayChange,
|
isPositive: viewModel.latestPortfolioChange.absolute >= 0,
|
||||||
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
|
||||||
isPositive: calmModeEnabled
|
|
||||||
? viewModel.latestPortfolioChange.absolute >= 0
|
|
||||||
: viewModel.isDayChangePositive,
|
|
||||||
forecast: showForecast ? viewModel.portfolioForecast : nil,
|
forecast: showForecast ? viewModel.portfolioForecast : nil,
|
||||||
isPremium: iapService.isPremium,
|
isPremium: iapService.isPremium,
|
||||||
onUnlockTap: {
|
onUnlockTap: {
|
||||||
@@ -373,10 +347,8 @@ struct DashboardView: View {
|
|||||||
onShareTap: {
|
onShareTap: {
|
||||||
ShareService.shared.sharePortfolioValue(
|
ShareService.shared.sharePortfolioValue(
|
||||||
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||||
changeText: calmModeEnabled
|
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
|
||||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
changeLabel: "since last check-in",
|
||||||
: viewModel.portfolioSummary.formattedDayChange,
|
|
||||||
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
|
||||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||||
)
|
)
|
||||||
@@ -449,19 +421,17 @@ struct DashboardView: View {
|
|||||||
PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate)
|
PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate)
|
||||||
}
|
}
|
||||||
case .periodReturns:
|
case .periodReturns:
|
||||||
if !calmModeEnabled {
|
if config.isCollapsed {
|
||||||
if config.isCollapsed {
|
CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange)
|
||||||
CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange)
|
} else {
|
||||||
} else {
|
PeriodReturnsCard(
|
||||||
PeriodReturnsCard(
|
monthChange: viewModel.portfolioSummary.formattedMonthChange,
|
||||||
monthChange: viewModel.portfolioSummary.formattedMonthChange,
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||||
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
isMonthPositive: viewModel.isMonthChangePositive,
|
||||||
isMonthPositive: viewModel.isMonthChangePositive,
|
isYearPositive: viewModel.isYearChangePositive,
|
||||||
isYearPositive: viewModel.isYearChangePositive,
|
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||||
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case .contributionsVsReturns:
|
case .contributionsVsReturns:
|
||||||
let contrib = viewModel.portfolioSummary.totalContributions
|
let contrib = viewModel.portfolioSummary.totalContributions
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ struct OnboardingView: View {
|
|||||||
// Default ON: a user who skips onboarding lands on a populated dashboard (instant
|
// Default ON: a user who skips onboarding lands on a populated dashboard (instant
|
||||||
// aha moment) instead of an empty screen. Sample data is clearly labeled and removable.
|
// aha moment) instead of an empty screen. Sample data is clearly labeled and removable.
|
||||||
@State private var useSampleData = true
|
@State private var useSampleData = true
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||||
@State private var showingImportSheet = false
|
@State private var showingImportSheet = false
|
||||||
@State private var showingAddSource = false
|
@State private var showingAddSource = false
|
||||||
@@ -53,7 +52,6 @@ struct OnboardingView: View {
|
|||||||
OnboardingQuickStartView(
|
OnboardingQuickStartView(
|
||||||
selectedCurrency: $selectedCurrency,
|
selectedCurrency: $selectedCurrency,
|
||||||
useSampleData: $useSampleData,
|
useSampleData: $useSampleData,
|
||||||
calmModeEnabled: $calmModeEnabled,
|
|
||||||
cloudSyncEnabled: $cloudSyncEnabled,
|
cloudSyncEnabled: $cloudSyncEnabled,
|
||||||
onImport: { showingImportSheet = true },
|
onImport: { showingImportSheet = true },
|
||||||
onAddSource: { showingAddSource = true }
|
onAddSource: { showingAddSource = true }
|
||||||
@@ -221,7 +219,6 @@ struct OnboardingPageView: View {
|
|||||||
struct OnboardingQuickStartView: View {
|
struct OnboardingQuickStartView: View {
|
||||||
@Binding var selectedCurrency: String
|
@Binding var selectedCurrency: String
|
||||||
@Binding var useSampleData: Bool
|
@Binding var useSampleData: Bool
|
||||||
@Binding var calmModeEnabled: Bool
|
|
||||||
@Binding var cloudSyncEnabled: Bool
|
@Binding var cloudSyncEnabled: Bool
|
||||||
let onImport: () -> Void
|
let onImport: () -> Void
|
||||||
let onAddSource: () -> Void
|
let onAddSource: () -> Void
|
||||||
@@ -293,15 +290,6 @@ struct OnboardingQuickStartView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Toggle("Focus Mode (recommended)", isOn: $calmModeEnabled)
|
|
||||||
Text("Shows returns since your last check-in instead of daily noise. You can change this anytime in Settings.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.cornerRadius(12)
|
|
||||||
|
|
||||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ struct SettingsView: View {
|
|||||||
private let iapService: IAPService
|
private let iapService: IAPService
|
||||||
@StateObject private var viewModel: SettingsViewModel
|
@StateObject private var viewModel: SettingsViewModel
|
||||||
@EnvironmentObject private var adMobService: AdMobService
|
@EnvironmentObject private var adMobService: AdMobService
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
@AppStorage("showForecast") private var showForecast = true
|
@AppStorage("showForecast") private var showForecast = true
|
||||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||||
@@ -706,8 +705,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var longTermSection: some View {
|
private var longTermSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Focus Mode", isOn: $calmModeEnabled)
|
|
||||||
|
|
||||||
Toggle("Show Forecast", isOn: $showForecast)
|
Toggle("Show Forecast", isOn: $showForecast)
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -723,7 +720,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Long-Term Focus")
|
Text("Long-Term Focus")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Focus Mode shows returns since your last check-in instead of daily fluctuations, and hides advanced technical charts. Turn it off for full access to all metrics and chart types.")
|
Text("The dashboard shows returns since your last check-in — steady progress over daily noise.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Charts
|
|||||||
struct SourceDetailView: View {
|
struct SourceDetailView: View {
|
||||||
@StateObject private var viewModel: SourceDetailViewModel
|
@StateObject private var viewModel: SourceDetailViewModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
|
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
@@ -45,11 +44,7 @@ struct SourceDetailView: View {
|
|||||||
headerCard
|
headerCard
|
||||||
|
|
||||||
// Performance Summary — full width, above quick actions
|
// Performance Summary — full width, above quick actions
|
||||||
if calmModeEnabled {
|
metricsSection
|
||||||
simpleMetricsSection
|
|
||||||
} else {
|
|
||||||
metricsSection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
quickActions
|
quickActions
|
||||||
@@ -145,24 +140,13 @@ struct SourceDetailView: View {
|
|||||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||||
|
|
||||||
// Return
|
// Return
|
||||||
if calmModeEnabled {
|
HStack(spacing: 4) {
|
||||||
VStack(spacing: 2) {
|
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
|
||||||
Text("All-time return")
|
Text(viewModel.formattedTotalReturn)
|
||||||
.font(.caption)
|
Text("(\(viewModel.formattedPercentageReturn))")
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
|
|
||||||
Text(viewModel.formattedTotalReturn)
|
|
||||||
Text("(\(viewModel.formattedPercentageReturn))")
|
|
||||||
}
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
|
||||||
}
|
}
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
||||||
|
|
||||||
// Last updated
|
// Last updated
|
||||||
Text("Last updated: \(viewModel.lastUpdated)")
|
Text("Last updated: \(viewModel.lastUpdated)")
|
||||||
@@ -402,27 +386,6 @@ struct SourceDetailView: View {
|
|||||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var simpleMetricsSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Performance Summary")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Snapshots Section
|
// MARK: - Snapshots Section
|
||||||
|
|
||||||
private var snapshotsSection: some View {
|
private var snapshotsSection: some View {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ struct SourceListView: View {
|
|||||||
@EnvironmentObject var accountStore: AccountStore
|
@EnvironmentObject var accountStore: AccountStore
|
||||||
@Environment(\.managedObjectContext) private var context
|
@Environment(\.managedObjectContext) private var context
|
||||||
@StateObject private var viewModel: SourceListViewModel
|
@StateObject private var viewModel: SourceListViewModel
|
||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
||||||
@State private var sourceToDelete: InvestmentSource?
|
@State private var sourceToDelete: InvestmentSource?
|
||||||
@State private var navigationPath = NavigationPath()
|
@State private var navigationPath = NavigationPath()
|
||||||
@State private var showingSearch = false
|
@State private var showingSearch = false
|
||||||
@@ -254,7 +253,7 @@ struct SourceListView: View {
|
|||||||
// Sources
|
// Sources
|
||||||
Section {
|
Section {
|
||||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
SourceRowView(source: source)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.background(
|
.background(
|
||||||
horizontalSizeClass == .regular && selectedSourceID == source.objectID
|
horizontalSizeClass == .regular && selectedSourceID == source.objectID
|
||||||
@@ -479,7 +478,6 @@ private struct MissingSourceView: View {
|
|||||||
|
|
||||||
struct SourceRowView: View {
|
struct SourceRowView: View {
|
||||||
@ObservedObject var source: InvestmentSource
|
@ObservedObject var source: InvestmentSource
|
||||||
let calmModeEnabled: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -519,19 +517,13 @@ struct SourceRowView: View {
|
|||||||
Text(source.latestValue.compactCurrencyString)
|
Text(source.latestValue.compactCurrencyString)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
if calmModeEnabled {
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||||
|
.font(.caption2)
|
||||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
|
||||||
.font(.caption2)
|
|
||||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
|
||||||
}
|
}
|
||||||
|
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|||||||
@@ -119,6 +119,44 @@ final class PortfolioJournalUITests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - App Store Screenshot Capture
|
// MARK: - App Store Screenshot Capture
|
||||||
|
|
||||||
|
/// Captures the redesigned iPad Charts screen (sidebar tiles + KPI header).
|
||||||
|
/// Works on both iPad (sidebar buttons) and iPhone (tab bar).
|
||||||
|
func testCaptureChartsScreen() throws {
|
||||||
|
let capture = XCUIApplication()
|
||||||
|
capture.launchArguments = ["--screenshots"]
|
||||||
|
capture.launch()
|
||||||
|
|
||||||
|
// Wait for main UI (tab bar on iPhone, sidebar on iPad)
|
||||||
|
Thread.sleep(forTimeInterval: 4.0)
|
||||||
|
|
||||||
|
// Dismiss the notification-permission system alert if it appears
|
||||||
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
let denyButton = springboard.alerts.buttons["Don't Allow"]
|
||||||
|
if denyButton.waitForExistence(timeout: 3) {
|
||||||
|
denyButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try tab bar first (iPhone), then any button labeled Charts (iPad sidebar)
|
||||||
|
let tabBar = capture.tabBars.firstMatch
|
||||||
|
if tabBar.exists && tabBar.buttons["Charts"].exists {
|
||||||
|
tabBar.buttons["Charts"].tap()
|
||||||
|
} else {
|
||||||
|
let chartsButton = capture.buttons["Charts"].firstMatch
|
||||||
|
if chartsButton.waitForExistence(timeout: 5) {
|
||||||
|
chartsButton.tap()
|
||||||
|
} else {
|
||||||
|
capture.staticTexts["Charts"].firstMatch.tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 3.0)
|
||||||
|
|
||||||
|
let shot = XCTAttachment(screenshot: capture.screenshot())
|
||||||
|
shot.name = "Charts_Redesign"
|
||||||
|
shot.lifetime = .keepAlways
|
||||||
|
add(shot)
|
||||||
|
}
|
||||||
|
|
||||||
/// Captures full-screen screenshots of the main tabs with demo data for App Store
|
/// Captures full-screen screenshots of the main tabs with demo data for App Store
|
||||||
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
|
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
|
||||||
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
|
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
|
||||||
|
|||||||
Reference in New Issue
Block a user