Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento
Charts iPad (rediseño): - Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a calculateMetrics producía KPIs absurdos) - Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección: Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad - Selector de periodo como Picker segmentado nativo en la cabecera del chart (fuera del sidebar); título + descripción del chart visibles - Eliminado sheet de paywall duplicado del layout iPad Zoom (todas las series temporales): - Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom - Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility Focus Mode eliminado (todo siempre disponible): - Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage - Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList - Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in - PeriodReturnsCard siempre visible Rendimiento y bugs (de la auditoría): - El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown - calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación lineal sobreestimaba con historiales cortos) - Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación real de las asignaciones simuladas del usuario - UITest de captura de Charts con manejo del alert de notificaciones 12 strings nuevas localizadas en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -16,7 +16,6 @@ enum ScreenshotMode {
|
||||
d.set(false, forKey: "pinEnabled")
|
||||
d.set(false, forKey: "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")
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "ズームをリセット";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Int> = []
|
||||
|
||||
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..<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)] {
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||
chartMonthStart(for: snapshot.date)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user