From db844d5e808549dba53987ad61f04b88f928b4c2 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 3 Jul 2026 10:17:10 +0200 Subject: [PATCH] =?UTF-8?q?Redise=C3=B1o=20Charts=20iPad,=20zoom=20en=20gr?= =?UTF-8?q?=C3=A1ficas,=20eliminaci=C3=B3n=20de=20Focus=20Mode=20y=20fixes?= =?UTF-8?q?=20de=20rendimiento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ --- .../App/PortfolioJournalApp.swift | 1 - .../Resources/de.lproj/Localizable.strings | 13 + .../Resources/en.lproj/Localizable.strings | 13 + .../Resources/es-ES.lproj/Localizable.strings | 13 + .../Resources/fr.lproj/Localizable.strings | 13 + .../Resources/it.lproj/Localizable.strings | 13 + .../Resources/ja.lproj/Localizable.strings | 13 + .../Resources/pt-BR.lproj/Localizable.strings | 13 + .../ViewModels/ChartsViewModel.swift | 101 ++++++-- .../ViewModels/DashboardViewModel.swift | 26 +- PortfolioJournal/Views/Charts/ChartZoom.swift | 149 ++++++++++++ .../Views/Charts/ChartsContainerView.swift | 229 +++++++++++------- .../Views/Charts/DrawdownChart.swift | 4 + .../Views/Dashboard/DashboardView.swift | 62 ++--- .../Views/Onboarding/OnboardingView.swift | 12 - .../Views/Settings/SettingsView.swift | 5 +- .../Views/Sources/SourceDetailView.swift | 51 +--- .../Views/Sources/SourceListView.swift | 18 +- .../PortfolioJournalUITests.swift | 38 +++ 19 files changed, 557 insertions(+), 230 deletions(-) create mode 100644 PortfolioJournal/Views/Charts/ChartZoom.swift diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index 16fe61b..15b5e07 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -16,7 +16,6 @@ enum ScreenshotMode { d.set(false, forKey: "pinEnabled") d.set(false, forKey: "lockOnLaunch") d.set(false, forKey: "lockOnBackground") - d.set(false, forKey: "calmModeEnabled") // show all charts for richer screenshots let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" d.set(version, forKey: "lastSeenWhatsNewVersion") } diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index 0401485..877a16e 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -433,3 +433,16 @@ "paywall_trial_format" = "%d %@ gratis"; "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"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 0fa26fc..9d54971 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -441,3 +441,16 @@ "paywall_trial_format" = "%d %@ free"; "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"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 3b8b2a7..84979c4 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -385,3 +385,16 @@ "paywall_trial_format" = "%d %@ gratis"; "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"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index 490e294..854c093 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -433,3 +433,16 @@ "paywall_trial_format" = "%d %@ gratuits"; "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"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 2744d9a..b2c4487 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -433,3 +433,16 @@ "paywall_trial_format" = "%d %@ gratis"; "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"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index fbb03c3..7939adc 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -433,3 +433,16 @@ "paywall_trial_format" = "%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" = "ズームをリセット"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index 6f89c19..0877fb7 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -433,3 +433,16 @@ "paywall_trial_format" = "%d %@ grátis"; "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"; diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 48c0564..8f410e2 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -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 @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). /// Drives the "unlock X more months" teaser in the charts screen. @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 = [] struct YearSeries: Identifiable { @@ -311,6 +303,13 @@ class ChartsViewModel: ObservableObject { self.lastBreakdown != selectedBreakdown 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.lastTimeRange = timeRange self.lastCategoryId = category?.id @@ -319,7 +318,9 @@ class ChartsViewModel: ObservableObject { self.lastAccountId = safeSelectedAccountId self.lastShowAllAccounts = showAll self.lastBreakdown = selectedBreakdown - self.cachedSnapshots = nil // Invalidate cache on meaningful changes + if dataSelectionChanged { + self.cachedSnapshots = nil + } self.updateChartData(chartType: chartType, category: category, timeRange: timeRange) } } @@ -468,6 +469,9 @@ class ChartsViewModel: ObservableObject { snapshots = snapshots.filter { $0.date >= cutoffDate } } + // Portfolio KPIs for the visible range (iPad header) + portfolioMetrics = computePortfolioKPIs(from: snapshots) + let completedSnapshots = filterSnapshotsForCharts( sources: sources, snapshots: snapshots @@ -1072,7 +1076,11 @@ class ChartsViewModel: ObservableObject { return } - var peak = totals.first!.totalValue + guard let firstTotal = totals.first else { + drawdownData = [] + return + } + var peak = firstTotal.totalValue var data: [(date: Date, drawdown: Double)] = [] 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 }) { simulatorSources = simulatorNew } else { - // Update currentPct but preserve simulatedPct - simulatorSources = simulatorNew + simulatorSources = zip(simulatorSources, simulatorNew).map { old, new in + var updated = new + updated.simulatedPct = old.simulatedPct + return updated + } } recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths) @@ -1540,6 +1552,65 @@ class ChartsViewModel: ObservableObject { 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.. 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)] { let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in chartMonthStart(for: snapshot.date) diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift index 64d4193..681f64f 100644 --- a/PortfolioJournal/ViewModels/DashboardViewModel.swift +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -494,8 +494,19 @@ class DashboardViewModel: ObservableObject { let totalGrowth = lastPoint.value - firstPoint.value let monthlyGrowth = totalGrowth / Decimal(monthsBetween) - // Project 12 months ahead - let forecastValue = currentValue + (monthlyGrowth * 12) + // Project 12 months ahead using compound growth (matches CAGR semantics); + // 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 let volatility = calculatePortfolioVolatility(from: values) @@ -537,10 +548,13 @@ class DashboardViewModel: ObservableObject { let monthsBetween = max(1, first.date.monthsBetween(last.date)) let totalReturn = (last.value - first.value) / first.value - // Annualize: (1 + total_return)^(12/months) - 1 - let monthlyReturn = totalReturn / Decimal(monthsBetween) - let annualized = monthlyReturn * 12 - return annualized + // Annualize compounding: (1 + total_return)^(12/months) - 1. + // The previous linear approximation (monthly*12) overstated short histories. + let totalReturnD = NSDecimalNumber(decimal: totalReturn).doubleValue + 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 { diff --git a/PortfolioJournal/Views/Charts/ChartZoom.swift b/PortfolioJournal/Views/Charts/ChartZoom.swift new file mode 100644 index 0000000..541cc9d --- /dev/null +++ b/PortfolioJournal/Views/Charts/ChartZoom.swift @@ -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) -> 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) + } +} diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index b64bfb1..019903d 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -5,7 +5,6 @@ struct ChartsContainerView: View { @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel: ChartsViewModel @StateObject private var goalsViewModel = GoalsViewModel() - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @AppStorage("showForecast") private var showForecast = true @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -26,7 +25,6 @@ struct ChartsContainerView: View { .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } - ToolbarItem(placement: .navigationBarLeading) { focusModeButton } } .onAppear { syncState() } .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) private var iPadChartsLayout: some View { HStack(spacing: 0) { - // Left sidebar: chart type list + filters + // Left sidebar: grouped chart-type tiles + contextual filters ScrollView { VStack(alignment: .leading, spacing: 0) { - Text("Chart Type") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 8) + ForEach(Self.chartGroups, id: \.titleKey) { group in + let types = group.types.filter { showForecast || $0 != .prediction } + if !types.isEmpty { + Text(String(localized: String.LocalizationValue(group.titleKey))) + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 6) - let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled) - .filter { showForecast || $0 != .prediction } - ForEach(types) { chartType in - 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) + LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) { + ForEach(types) { chartType in + iPadChartTypeTile(chartType) + } } - Text("Focus Mode") - .font(.subheadline) - .foregroundColor(.primary) - Spacer() - Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle") - .foregroundColor(calmModeEnabled ? .appPrimary : .secondary) - .font(.system(size: 16)) + .padding(.horizontal, 12) } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear) } - .buttonStyle(.plain) if hasAnyFilter { Divider().padding(.vertical, 12) filtersSection .padding(.horizontal, 16) .padding(.bottom, 16) + } else { + Spacer().frame(height: 16) } } } - .frame(width: 220) + .frame(width: 248) .background(Color(.systemBackground)) Divider() - // Right panel: full-width chart + // Right panel: KPI header + period control + chart ZStack { AppBackground() ScrollView { - VStack(spacing: 20) { + VStack(spacing: 16) { + kpiHeader + + chartToolbar + if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) .onAppear { @@ -150,7 +111,79 @@ struct ChartsContainerView: View { } } .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 @@ -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 isPremiumLocked = chartType.isPremium && !viewModel.isPremium return Button { viewModel.selectChart(chartType) } label: { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.appPrimary : Color(.systemGray5)) - .frame(width: 32, height: 32) + VStack(spacing: 6) { + ZStack(alignment: .topTrailing) { Image(systemName: chartType.icon) - .font(.system(size: 14)) - .foregroundColor(isSelected ? .white : .primary) + .font(.system(size: 18)) + .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) - .font(.subheadline) - .foregroundColor(isPremiumLocked ? .secondary : .primary) - Spacer() - if isPremiumLocked { - Image(systemName: "lock.fill") - .font(.caption2) - .foregroundColor(.secondary) - } + .font(.caption2.weight(isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : .primary) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.8) + .frame(height: 26, alignment: .top) } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear) + .padding(.vertical, 8) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity) + .background(isSelected ? Color.appPrimary : Color(.systemGray6)) + .cornerRadius(10) } .buttonStyle(.plain) + .accessibilityLabel(chartType.rawValue) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) } // MARK: - Shared @@ -253,7 +292,7 @@ struct ChartsContainerView: View { private var chartTypeSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { - ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in + ForEach(viewModel.availableChartTypes.filter { showForecast || $0 != .prediction }) { chartType in ChartTypeButton( chartType: chartType, isSelected: viewModel.selectedChartType == chartType, @@ -272,11 +311,13 @@ struct ChartsContainerView: View { private var hasAnyFilter: Bool { let chartType = viewModel.selectedChartType + let isPad = horizontalSizeClass == .regular // These chart types manage their own controls internally, no external filter bar needed 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 hasSources = !viewModel.availableSources(for: chartType).isEmpty let hasBreakdown = chartType == .allocation || chartType == .performance @@ -289,8 +330,10 @@ struct ChartsContainerView: View { VStack(alignment: .leading, spacing: 10) { let chartType = viewModel.selectedChartType - // Time Range (not for performance — uses slider) - if chartType != .allocation && chartType != .riskReturn && chartType != .performance { + // Time Range (not for performance — uses slider). On iPad the period + // picker lives in the chart toolbar, not the sidebar. + if horizontalSizeClass != .regular + && chartType != .allocation && chartType != .riskReturn && chartType != .performance { filterRow(icon: "calendar", label: "Period") { timeRangeSelector } @@ -722,6 +765,7 @@ struct ChartTypeButton: View { struct EvolutionChartView: View { let data: [(date: Date, value: Decimal)] + @State private var zoom = ChartZoomModel() let categoryData: [CategoryEvolutionPoint] let goals: [Goal] @@ -933,6 +977,7 @@ struct EvolutionChartView: View { } } .frame(height: 300) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Performance: Use GPU rendering for smoother scrolling on older devices .drawingGroup() } @@ -1025,6 +1070,7 @@ struct EvolutionChartView: View { struct ContributionsChartView: View { let data: [(date: Date, amount: Decimal)] + @State private var zoom = ChartZoomModel() var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -1062,6 +1108,7 @@ struct ContributionsChartView: View { } } .frame(height: 260) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: contributionStats).padding(.top, 4) } } @@ -1091,6 +1138,7 @@ struct ContributionsChartView: View { struct RollingReturnChartView: View { let data: [(date: Date, value: Double)] + @State private var zoom = ChartZoomModel() var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -1135,6 +1183,7 @@ struct RollingReturnChartView: View { } } .frame(height: 260) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: rollingStats).padding(.top, 4) ChartDataTable( rows: rollingTableRows, @@ -1250,6 +1299,7 @@ struct RiskReturnChartView: View { struct CashflowStackedChartView: View { let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] + @State private var zoom = ChartZoomModel() var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -1298,6 +1348,7 @@ struct CashflowStackedChartView: View { } } .frame(height: 260) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: cashflowStats).padding(.top, 4) } } diff --git a/PortfolioJournal/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift index b8be431..0a3ed3a 100644 --- a/PortfolioJournal/Views/Charts/DrawdownChart.swift +++ b/PortfolioJournal/Views/Charts/DrawdownChart.swift @@ -3,6 +3,7 @@ import Charts struct DrawdownChart: View { let data: [(date: Date, drawdown: Double)] + @State private var zoom = ChartZoomModel() var maxDrawdown: Double { abs(data.map { $0.drawdown }.min() ?? 0) @@ -74,6 +75,7 @@ struct DrawdownChart: View { } .chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0) .frame(height: 250) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Statistics HStack(spacing: 20) { @@ -164,6 +166,7 @@ struct DrawdownStatView: View { struct VolatilityChartView: View { let data: [(date: Date, volatility: Double)] + @State private var zoom = ChartZoomModel() var currentVolatility: Double { data.last?.volatility ?? 0 @@ -236,6 +239,7 @@ struct VolatilityChartView: View { } } .frame(height: 250) + .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: [ ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)), diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index e3628e9..882b346 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -11,7 +11,6 @@ struct DashboardView: View { @State private var showingAddSource = false @State private var showingCustomize = false @State private var sectionConfigs = DashboardLayoutStore.load() - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @AppStorage("showForecast") private var showForecast = true @State private var pendingAlertDismissed = false @State private var fullScreenSection: DashboardSection? = nil @@ -103,9 +102,6 @@ struct DashboardView: View { Image(systemName: "slider.horizontal.3") } } - ToolbarItem(placement: .navigationBarLeading) { - dashboardFocusModeButton - } } .onAppear { 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 { HStack(spacing: 6) { Image(systemName: "flame.fill") @@ -354,13 +332,9 @@ struct DashboardView: View { } else { TotalValueCard( totalValue: viewModel.portfolioSummary.formattedTotalValue, - changeText: calmModeEnabled - ? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))" - : viewModel.portfolioSummary.formattedDayChange, - changeLabel: calmModeEnabled ? "since last check-in" : "today", - isPositive: calmModeEnabled - ? viewModel.latestPortfolioChange.absolute >= 0 - : viewModel.isDayChangePositive, + changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))", + changeLabel: "since last check-in", + isPositive: viewModel.latestPortfolioChange.absolute >= 0, forecast: showForecast ? viewModel.portfolioForecast : nil, isPremium: iapService.isPremium, onUnlockTap: { @@ -373,10 +347,8 @@ struct DashboardView: View { onShareTap: { ShareService.shared.sharePortfolioValue( totalValue: viewModel.portfolioSummary.formattedTotalValue, - changeText: calmModeEnabled - ? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))" - : viewModel.portfolioSummary.formattedDayChange, - changeLabel: calmModeEnabled ? "since last check-in" : "today", + changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))", + changeLabel: "since last check-in", yearChange: viewModel.portfolioSummary.formattedYearChange, sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn ) @@ -449,19 +421,17 @@ struct DashboardView: View { PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate) } case .periodReturns: - if !calmModeEnabled { - if config.isCollapsed { - CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange) - } else { - PeriodReturnsCard( - monthChange: viewModel.portfolioSummary.formattedMonthChange, - yearChange: viewModel.portfolioSummary.formattedYearChange, - allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn, - isMonthPositive: viewModel.isMonthChangePositive, - isYearPositive: viewModel.isYearChangePositive, - isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0 - ) - } + if config.isCollapsed { + CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange) + } else { + PeriodReturnsCard( + monthChange: viewModel.portfolioSummary.formattedMonthChange, + yearChange: viewModel.portfolioSummary.formattedYearChange, + allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn, + isMonthPositive: viewModel.isMonthChangePositive, + isYearPositive: viewModel.isYearChangePositive, + isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0 + ) } case .contributionsVsReturns: let contrib = viewModel.portfolioSummary.totalContributions diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index 2ac1798..8595044 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -9,7 +9,6 @@ struct OnboardingView: View { // 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. @State private var useSampleData = true - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false @State private var showingImportSheet = false @State private var showingAddSource = false @@ -53,7 +52,6 @@ struct OnboardingView: View { OnboardingQuickStartView( selectedCurrency: $selectedCurrency, useSampleData: $useSampleData, - calmModeEnabled: $calmModeEnabled, cloudSyncEnabled: $cloudSyncEnabled, onImport: { showingImportSheet = true }, onAddSource: { showingAddSource = true } @@ -221,7 +219,6 @@ struct OnboardingPageView: View { struct OnboardingQuickStartView: View { @Binding var selectedCurrency: String @Binding var useSampleData: Bool - @Binding var calmModeEnabled: Bool @Binding var cloudSyncEnabled: Bool let onImport: () -> Void let onAddSource: () -> Void @@ -293,15 +290,6 @@ struct OnboardingQuickStartView: View { .background(Color.gray.opacity(0.1)) .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) .padding() diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index ee7256d..c7c8486 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -6,7 +6,6 @@ struct SettingsView: View { private let iapService: IAPService @StateObject private var viewModel: SettingsViewModel @EnvironmentObject private var adMobService: AdMobService - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @AppStorage("showForecast") private var showForecast = true @AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false @AppStorage("faceIdEnabled") private var faceIdEnabled = false @@ -706,8 +705,6 @@ struct SettingsView: View { private var longTermSection: some View { Section { - Toggle("Focus Mode", isOn: $calmModeEnabled) - Toggle("Show Forecast", isOn: $showForecast) NavigationLink { @@ -723,7 +720,7 @@ struct SettingsView: View { } header: { Text("Long-Term Focus") } 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.") } } diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift index 342f9c4..8398097 100644 --- a/PortfolioJournal/Views/Sources/SourceDetailView.swift +++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift @@ -5,7 +5,6 @@ import Charts struct SourceDetailView: View { @StateObject private var viewModel: SourceDetailViewModel @Environment(\.dismiss) private var dismiss - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @State private var showingDeleteConfirmation = false @Environment(\.managedObjectContext) private var viewContext @@ -45,11 +44,7 @@ struct SourceDetailView: View { headerCard // Performance Summary — full width, above quick actions - if calmModeEnabled { - simpleMetricsSection - } else { - metricsSection - } + metricsSection // Quick Actions quickActions @@ -145,24 +140,13 @@ struct SourceDetailView: View { .font(.system(size: 36, weight: .bold, design: .rounded)) // Return - if calmModeEnabled { - VStack(spacing: 2) { - Text("All-time return") - .font(.caption) - .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) + 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) // Last updated Text("Last updated: \(viewModel.lastUpdated)") @@ -402,27 +386,6 @@ struct SourceDetailView: View { .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 private var snapshotsSection: some View { diff --git a/PortfolioJournal/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift index df411bf..5c4c8bb 100644 --- a/PortfolioJournal/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -6,7 +6,6 @@ struct SourceListView: View { @EnvironmentObject var accountStore: AccountStore @Environment(\.managedObjectContext) private var context @StateObject private var viewModel: SourceListViewModel - @AppStorage("calmModeEnabled") private var calmModeEnabled = true @State private var sourceToDelete: InvestmentSource? @State private var navigationPath = NavigationPath() @State private var showingSearch = false @@ -254,7 +253,7 @@ struct SourceListView: View { // Sources Section { ForEach(viewModel.sources, id: \.objectID) { source in - SourceRowView(source: source, calmModeEnabled: calmModeEnabled) + SourceRowView(source: source) .contentShape(Rectangle()) .background( horizontalSizeClass == .regular && selectedSourceID == source.objectID @@ -479,7 +478,6 @@ private struct MissingSourceView: View { struct SourceRowView: View { @ObservedObject var source: InvestmentSource - let calmModeEnabled: Bool var body: some View { HStack(spacing: 12) { @@ -519,19 +517,13 @@ struct SourceRowView: View { Text(source.latestValue.compactCurrencyString) .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)) .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) diff --git a/PortfolioJournalUITests/PortfolioJournalUITests.swift b/PortfolioJournalUITests/PortfolioJournalUITests.swift index 15e8398..076943f 100644 --- a/PortfolioJournalUITests/PortfolioJournalUITests.swift +++ b/PortfolioJournalUITests/PortfolioJournalUITests.swift @@ -119,6 +119,44 @@ final class PortfolioJournalUITests: XCTestCase { // 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 /// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped, /// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var