iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab. - Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho regular, stack colapsable en compacto) con selección nativa de List. - Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos en regular, sheet en compacto). - Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:), sin ignoresSafeArea en contenido interactivo. - Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
import TipKit
|
||||
|
||||
struct ChartsContainerView: View {
|
||||
@@ -28,10 +29,10 @@ struct ChartsContainerView: View {
|
||||
// pattern) — replaces the 14-chip horizontal carousel.
|
||||
.toolbarTitleMenu { chartTypePicker }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||||
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
|
||||
ToolbarItem(placement: .topBarTrailing) { accountFilterMenu }
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenu }
|
||||
if horizontalSizeClass != .regular {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { shareButton }
|
||||
}
|
||||
}
|
||||
.onAppear { syncState() }
|
||||
@@ -105,11 +106,14 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Regular width has a wide canvas next to the sidebar: give
|
||||
// every plot ~35% more height so the extra area shows data,
|
||||
// not just a stretched iPhone chart.
|
||||
.environment(\.chartHeightScale, 1.35)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
// Attached here (not on the layout-switching Group): sheets presented from
|
||||
// a view that gets replaced when the size class changes were unreliable in
|
||||
// NavigationSplitView — premium tiles looked like they "did nothing".
|
||||
@@ -144,7 +148,7 @@ struct ChartsContainerView: View {
|
||||
if chartType.isPremium && !viewModel.isPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,19 +469,19 @@ struct ChartsContainerView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text(value)
|
||||
.font(.system(.title3, design: .rounded).weight(.semibold))
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
}
|
||||
|
||||
@@ -492,7 +496,7 @@ struct ChartsContainerView: View {
|
||||
.font(.headline)
|
||||
Text(viewModel.selectedChartType.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
@@ -589,10 +593,10 @@ struct ChartsContainerView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(12)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -673,7 +677,7 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.foregroundStyle(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.background(
|
||||
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
@@ -706,7 +710,7 @@ struct ChartsContainerView: View {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||||
@@ -777,7 +781,7 @@ struct ChartsContainerView: View {
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(showRangeBrush ? .white : .appPrimary)
|
||||
.foregroundStyle(showRangeBrush ? .white : Color.appPrimary)
|
||||
.frame(width: 38, height: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
@@ -899,7 +903,7 @@ struct ChartsContainerView: View {
|
||||
premiumLockedView
|
||||
} else if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
} else if !viewModel.hasData {
|
||||
emptyStateView
|
||||
} else {
|
||||
@@ -975,10 +979,10 @@ struct ChartsContainerView: View {
|
||||
.frame(width: 88, height: 88)
|
||||
Image(systemName: viewModel.selectedChartType.icon)
|
||||
.font(.system(size: 34))
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
.background(Circle().fill(Color(.systemBackground)))
|
||||
.offset(x: 32, y: 30)
|
||||
}
|
||||
@@ -988,7 +992,7 @@ struct ChartsContainerView: View {
|
||||
|
||||
Text(viewModel.selectedChartType.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
@@ -1000,14 +1004,14 @@ struct ChartsContainerView: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(24)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, minHeight: 320)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1015,17 +1019,17 @@ struct ChartsContainerView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("No Data Available")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add some investment sources and snapshots to see charts.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
@@ -1167,10 +1171,10 @@ struct EvolutionChartView: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.foregroundStyle(Color.appWarning)
|
||||
Text(insight)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !data.isEmpty && chartMode == .total {
|
||||
@@ -1179,7 +1183,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1228,7 +1232,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(showContributions ? .appSecondary : .secondary)
|
||||
.foregroundStyle(showContributions ? Color.appSecondary : .secondary)
|
||||
.background(
|
||||
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
|
||||
)
|
||||
@@ -1307,7 +1311,7 @@ struct EvolutionChartView: View {
|
||||
if series.count > 1 {
|
||||
Text(String(format: "%+.1f%%", deltaPct))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
|
||||
.foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
@@ -1317,7 +1321,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -1326,7 +1330,7 @@ struct EvolutionChartView: View {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
@@ -1349,8 +1353,8 @@ struct EvolutionChartView: View {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 300)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 300)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,7 +1418,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
.chartFrame(height: 300)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
// Performance: GPU rendering for smoother scrolling on older devices
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
@@ -1435,7 +1439,7 @@ struct EvolutionChartView: View {
|
||||
series: .value("Series", "total")
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
LinearGradient(colors: [Color.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
|
||||
@@ -1571,8 +1575,8 @@ struct ContributionsChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("No contributions yet.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
@@ -1581,7 +1585,7 @@ struct ContributionsChartView: View {
|
||||
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.cornerRadius(6)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
@@ -1599,14 +1603,14 @@ struct ContributionsChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1649,8 +1653,8 @@ struct RollingReturnChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data for rolling returns.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
@@ -1706,7 +1710,7 @@ struct RollingReturnChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
||||
ChartDataTable(
|
||||
@@ -1719,7 +1723,7 @@ struct RollingReturnChartView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1771,8 +1775,8 @@ struct RiskReturnChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare categories.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.category) { item in
|
||||
@@ -1785,7 +1789,7 @@ struct RiskReturnChartView: View {
|
||||
.annotation(position: .top) {
|
||||
Text(item.category)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1809,12 +1813,12 @@ struct RiskReturnChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
@@ -1832,8 +1836,8 @@ struct CashflowStackedChartView: View {
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare cashflow.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
@@ -1871,14 +1875,14 @@ struct CashflowStackedChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
@@ -1934,14 +1938,14 @@ struct AllocationEvolutionChart: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
Text("Not enough data to show allocation evolution.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
.foregroundStyle(.secondary)
|
||||
.chartFrame(height: 260)
|
||||
}
|
||||
|
||||
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
|
||||
@@ -1989,7 +1993,7 @@ struct AllocationEvolutionChart: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartFrame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user