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:
alexandrev-tibco
2026-07-03 10:17:10 +02:00
parent d0a51d0162
commit db844d5e80
19 changed files with 557 additions and 230 deletions
@@ -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