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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user