Charts 2.0 (build 49): sidebar nativa, filtros en toolbar, title menu en iPhone
El bug de Allocation/Contributions que no respondían en iPad persistía tras dos fixes de lógica — la causa estaba en la capa de interacción (Buttons custom en LazyVGrid dentro de ScrollView dentro de NavigationSplitView). Rediseño completo alineado con el HIG: - iPad: List(selection:) nativa con estilo .sidebar y secciones agrupadas (Overview/Analyze/Risk/Forecast) — hit-testing del sistema, navegación por teclado, pointer effects y VoiceOver gratis. El gating premium sigue en selectChart vía el binding de selección. - Filtros (Group/Category/Sources) → Menu nativo en la toolbar con checkmarks e icono con badge cuando hay filtros activos. Desaparecen las pills apiladas. - iPhone: toolbarTitleMenu — el título es el selector de chart (patrón Files/Freeform); fuera el carrusel de 14 chips. Periodo como segmented picker estilo Stocks encima de la gráfica. - Slider de Performance integrado en el toolbar del chart (iPad) y como card compacta (iPhone). - Eliminadas ~300 líneas de selectores custom (tiles, pills, chips). Verificado con UITest nuevo (testChartSidebarSelection) en simulador iPad Pro: tap en Allocation/Contributions/Rolling 12M carga cada chart, y el filtro de periodo de Rolling reacciona. ScreenshotMode ahora activa debugPremiumOverride para poder testear charts premium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -541,7 +541,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -582,7 +582,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -739,7 +739,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -774,7 +774,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -805,7 +805,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -829,7 +829,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -852,7 +852,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -875,7 +875,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -899,7 +899,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
@@ -927,7 +927,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
||||
@@ -18,6 +18,9 @@ enum ScreenshotMode {
|
||||
d.set(false, forKey: "lockOnBackground")
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
||||
// Unlock premium (DEBUG-only override) so captures/UI tests can exercise
|
||||
// the premium charts without a StoreKit purchase.
|
||||
d.set(true, forKey: "debugPremiumOverride")
|
||||
}
|
||||
|
||||
/// Seed demo data (no-op if the store already has sources). Called once Core Data
|
||||
|
||||
@@ -21,9 +21,14 @@ struct ChartsContainerView: View {
|
||||
iPhoneChartsLayout
|
||||
}
|
||||
}
|
||||
.navigationTitle("Charts")
|
||||
.navigationTitle(horizontalSizeClass == .regular ? "Charts" : viewModel.selectedChartType.rawValue)
|
||||
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .automatic : .inline)
|
||||
// iPhone: the title doubles as the chart switcher (Files/Freeform
|
||||
// pattern) — replaces the 14-chip horizontal carousel.
|
||||
.toolbarTitleMenu { chartTypePicker }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||||
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
|
||||
if horizontalSizeClass != .regular {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
||||
}
|
||||
@@ -51,40 +56,24 @@ struct ChartsContainerView: View {
|
||||
|
||||
private var iPadChartsLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left sidebar: grouped chart-type tiles + contextual filters
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
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)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) {
|
||||
ForEach(types) { chartType in
|
||||
iPadChartTypeTile(chartType)
|
||||
}
|
||||
// Left sidebar: native List selection (system hit-testing, keyboard
|
||||
// navigation, pointer effects) — replaces the custom tile grid whose
|
||||
// Buttons were unreliable inside NavigationSplitView.
|
||||
List(selection: sidebarSelection) {
|
||||
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||||
let types = group.types.filter { showForecast || $0 != .prediction }
|
||||
if !types.isEmpty {
|
||||
Section(String(localized: String.LocalizationValue(group.titleKey))) {
|
||||
ForEach(types) { chartType in
|
||||
sidebarRow(chartType)
|
||||
.tag(chartType)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
|
||||
if hasAnyFilter {
|
||||
Divider().padding(.vertical, 12)
|
||||
filtersSection
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
} else {
|
||||
Spacer().frame(height: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.frame(width: 248)
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -128,6 +117,141 @@ struct ChartsContainerView: View {
|
||||
("chart_group_forecast", [.prediction, .simulator])
|
||||
]
|
||||
|
||||
/// Routes List selection through selectChart so premium gating (paywall)
|
||||
/// stays in one place; a rejected selection simply reverts on re-render.
|
||||
private var sidebarSelection: Binding<ChartsViewModel.ChartType?> {
|
||||
Binding(
|
||||
get: { viewModel.selectedChartType },
|
||||
set: { newValue in
|
||||
guard let newValue else { return }
|
||||
viewModel.selectChart(newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||||
HStack {
|
||||
Label(chartType.rawValue, systemImage: chartType.icon)
|
||||
Spacer()
|
||||
if chartType.isPremium && !viewModel.isPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grouped chart switcher for the title menu (iPhone) — native Picker rows
|
||||
/// get the system checkmark on the active chart.
|
||||
private var chartTypePicker: some View {
|
||||
Picker("Chart", selection: sidebarSelection) {
|
||||
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||||
let types = group.types.filter { showForecast || $0 != .prediction }
|
||||
if !types.isEmpty {
|
||||
Section(String(localized: String.LocalizationValue(group.titleKey))) {
|
||||
ForEach(types) { chartType in
|
||||
Label(
|
||||
chartType.rawValue,
|
||||
systemImage: (chartType.isPremium && !viewModel.isPremium) ? "lock.fill" : chartType.icon
|
||||
)
|
||||
.tag(ChartsViewModel.ChartType?.some(chartType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filters (toolbar menu)
|
||||
|
||||
private var hasActiveFilters: Bool {
|
||||
let breakdownApplies = viewModel.selectedChartType == .allocation || viewModel.selectedChartType == .performance
|
||||
return viewModel.selectedCategory != nil
|
||||
|| !viewModel.selectedSourceIds.isEmpty
|
||||
|| (breakdownApplies && viewModel.selectedBreakdown != .category)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var filterMenu: some View {
|
||||
let chartType = viewModel.selectedChartType
|
||||
let categories = viewModel.availableCategories(for: chartType)
|
||||
let sources = viewModel.availableSources(for: chartType)
|
||||
let hasBreakdown = chartType == .allocation || chartType == .performance
|
||||
if hasBreakdown || !categories.isEmpty || !sources.isEmpty {
|
||||
Menu {
|
||||
if hasBreakdown {
|
||||
Picker("Group", selection: $viewModel.selectedBreakdown) {
|
||||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
if !categories.isEmpty {
|
||||
Picker("Category", selection: categorySelection) {
|
||||
Text("All Categories").tag(Category?.none)
|
||||
ForEach(categories) { category in
|
||||
Text(category.name).tag(Optional(category))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
if !sources.isEmpty {
|
||||
Menu("Sources") {
|
||||
ForEach(sources) { source in
|
||||
Toggle(source.name, isOn: sourceToggle(source))
|
||||
}
|
||||
if !viewModel.selectedSourceIds.isEmpty {
|
||||
Divider()
|
||||
Button("Show All Sources") {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasActiveFilters {
|
||||
Divider()
|
||||
Button("Reset Filters", role: .destructive) {
|
||||
viewModel.selectedCategory = nil
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
viewModel.selectedBreakdown = .category
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: hasActiveFilters
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel("Filters")
|
||||
}
|
||||
}
|
||||
|
||||
private var categorySelection: Binding<Category?> {
|
||||
Binding(
|
||||
get: { viewModel.selectedCategory },
|
||||
set: { newValue in
|
||||
viewModel.selectedCategory = newValue
|
||||
viewModel.selectedSource = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func sourceToggle(_ source: InvestmentSource) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.selectedSourceIds.contains(source.id) },
|
||||
set: { isOn in
|
||||
viewModel.selectedSource = nil
|
||||
if isOn {
|
||||
viewModel.selectedSourceIds.insert(source.id)
|
||||
} else {
|
||||
viewModel.selectedSourceIds.remove(source.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - KPI Header (iPad)
|
||||
|
||||
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
|
||||
@@ -299,7 +423,10 @@ struct ChartsContainerView: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if !ranges.isEmpty && viewModel.selectedChartType != .performance {
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performanceSlider
|
||||
.frame(maxWidth: 360)
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
@@ -313,6 +440,24 @@ struct ChartsContainerView: View {
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var performanceSlider: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text("Period: \(periodLabel(viewModel.performancePeriodMonths))")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 92, alignment: .leading)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(viewModel.performancePeriodMonths) },
|
||||
set: { viewModel.performancePeriodMonths = Int($0) }
|
||||
),
|
||||
in: 1...60,
|
||||
step: 1
|
||||
)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private var shareButton: some View {
|
||||
Button {
|
||||
shareCurrentChart()
|
||||
@@ -366,14 +511,13 @@ struct ChartsContainerView: View {
|
||||
AppBackground()
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
chartTypeSelector
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||
}
|
||||
}
|
||||
filtersSection
|
||||
iPhonePeriodControl
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
@@ -385,44 +529,24 @@ struct ChartsContainerView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
// MARK: - iPad Chart Type Tile
|
||||
|
||||
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: {
|
||||
VStack(spacing: 6) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: chartType.icon)
|
||||
.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)
|
||||
}
|
||||
/// Stocks-style segmented period control above the chart (the chart switcher
|
||||
/// lives in the title menu, filters in the toolbar).
|
||||
@ViewBuilder
|
||||
private var iPhonePeriodControl: some View {
|
||||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performanceSlider
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
}
|
||||
Text(chartType.rawValue)
|
||||
.font(.caption2.weight(isSelected ? .semibold : .regular))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(0.8)
|
||||
.frame(height: 26, alignment: .top)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isSelected ? Color.appPrimary : Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(chartType.rawValue)
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
@@ -437,306 +561,6 @@ struct ChartsContainerView: View {
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Selector
|
||||
|
||||
private var chartTypeSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.availableChartTypes.filter { showForecast || $0 != .prediction }) { chartType in
|
||||
ChartTypeButton(
|
||||
chartType: chartType,
|
||||
isSelected: viewModel.selectedChartType == chartType,
|
||||
isPremium: chartType.isPremium,
|
||||
userIsPremium: viewModel.isPremium
|
||||
) {
|
||||
viewModel.selectChart(chartType)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified Filters Section
|
||||
|
||||
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 {
|
||||
// On iPad the period picker lives in the chart toolbar, not the filter bar.
|
||||
return isPad ? false : !viewModel.availableTimeRanges(for: chartType).isEmpty
|
||||
}
|
||||
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
|
||||
return hasTimeRange || hasCategories || hasSources || hasBreakdown
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var filtersSection: some View {
|
||||
if hasAnyFilter {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
let chartType = viewModel.selectedChartType
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Performance: custom period slider
|
||||
if chartType == .performance {
|
||||
filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(viewModel.performancePeriodMonths) },
|
||||
set: { viewModel.performancePeriodMonths = Int($0) }
|
||||
),
|
||||
in: 1...60,
|
||||
step: 1
|
||||
)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Breakdown (category vs source)
|
||||
if chartType == .allocation || chartType == .performance {
|
||||
filterRow(icon: "square.grid.2x2", label: "Group") {
|
||||
breakdownSelector
|
||||
}
|
||||
}
|
||||
|
||||
// Category
|
||||
let availableCategories = viewModel.availableCategories(for: chartType)
|
||||
if !availableCategories.isEmpty {
|
||||
filterRow(icon: "tag", label: "Category") {
|
||||
categoryFilter
|
||||
}
|
||||
}
|
||||
|
||||
// Source
|
||||
let availableSources = viewModel.availableSources(for: chartType)
|
||||
if !availableSources.isEmpty {
|
||||
filterRow(icon: "building.2", label: "Source") {
|
||||
sourceFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterRow<Content: View>(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(label)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Time Range Selector
|
||||
|
||||
private var timeRangeSelector: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
|
||||
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
||||
timeRangeButton(range, fullWidth: true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
||||
timeRangeButton(range, fullWidth: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View {
|
||||
Button {
|
||||
viewModel.selectedTimeRange = range
|
||||
} label: {
|
||||
Text(range.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.frame(maxWidth: fullWidth ? .infinity : nil)
|
||||
.padding(.horizontal, fullWidth ? 8 : 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(viewModel.selectedTimeRange == range ? Color.appPrimary : Color.gray.opacity(0.1))
|
||||
.foregroundColor(viewModel.selectedTimeRange == range ? .white : .primary)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var categoryFilter: some View {
|
||||
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||||
if !availableCategories.isEmpty {
|
||||
if horizontalSizeClass == .regular {
|
||||
VStack(spacing: 4) {
|
||||
if availableCategories.count > 1 {
|
||||
categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) {
|
||||
viewModel.selectedCategory = nil
|
||||
}
|
||||
}
|
||||
ForEach(availableCategories) { category in
|
||||
categoryPill(label: category.name, color: category.color,
|
||||
isSelected: viewModel.selectedCategory?.id == category.id) {
|
||||
viewModel.selectedCategory = category
|
||||
viewModel.selectedSource = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableCategories.count > 1 {
|
||||
categoryPill(label: "All", color: .appPrimary,
|
||||
isSelected: viewModel.selectedCategory == nil) {
|
||||
viewModel.selectedCategory = nil
|
||||
}
|
||||
}
|
||||
ForEach(availableCategories) { category in
|
||||
categoryPill(label: category.name, color: category.color,
|
||||
isSelected: viewModel.selectedCategory?.id == category.id) {
|
||||
viewModel.selectedCategory = category
|
||||
viewModel.selectedSource = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? color : Color.gray.opacity(0.1))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceFilter: some View {
|
||||
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
if !availableSources.isEmpty {
|
||||
if horizontalSizeClass == .regular {
|
||||
VStack(spacing: 4) {
|
||||
if availableSources.count > 1 {
|
||||
sourcePill(label: "All Sources", color: .appPrimary,
|
||||
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
}
|
||||
}
|
||||
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
||||
let isSelected = viewModel.selectedSourceIds.contains(source.id)
|
||||
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
|
||||
viewModel.selectedSource = nil
|
||||
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
|
||||
else { viewModel.selectedSourceIds.insert(source.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableSources.count > 1 {
|
||||
sourcePill(label: "All Sources", color: .appPrimary,
|
||||
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
}
|
||||
}
|
||||
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
||||
let isSelected = viewModel.selectedSourceIds.contains(source.id)
|
||||
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
|
||||
viewModel.selectedSource = nil
|
||||
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
|
||||
else { viewModel.selectedSourceIds.insert(source.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? color : Color.gray.opacity(0.1))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Breakdown Selector
|
||||
|
||||
private var breakdownSelector: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||
breakdownButton(mode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||
breakdownButton(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View {
|
||||
Button {
|
||||
viewModel.selectedBreakdown = mode
|
||||
} label: {
|
||||
Text(mode.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1))
|
||||
.foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
}
|
||||
|
||||
private func periodLabel(_ months: Int) -> String {
|
||||
if months < 12 {
|
||||
return "\(months)M"
|
||||
@@ -864,53 +688,6 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Button
|
||||
|
||||
struct ChartTypeButton: View {
|
||||
let chartType: ChartsViewModel.ChartType
|
||||
let isSelected: Bool
|
||||
let isPremium: Bool
|
||||
let userIsPremium: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: chartType.icon)
|
||||
.font(.title2)
|
||||
|
||||
if isPremium && !userIsPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.offset(x: 8, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(chartType.rawValue)
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(width: 80, height: 70)
|
||||
.background(
|
||||
isSelected
|
||||
? LinearGradient.appPrimaryGradient
|
||||
: LinearGradient(
|
||||
colors: [
|
||||
Color(.systemBackground).opacity(0.85),
|
||||
Color(.systemBackground).opacity(0.85)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Evolution Chart View
|
||||
|
||||
struct EvolutionChartView: View {
|
||||
|
||||
@@ -189,6 +189,74 @@ final class PortfolioJournalUITests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression (1.4.2): selecting Allocation / Contributions / Rolling 12M in the
|
||||
/// Charts sidebar must actually switch the chart — taps on the old custom tiles
|
||||
/// were unreliable on iPad. Also verifies the Rolling 12M period filter reacts.
|
||||
func testChartSidebarSelection() throws {
|
||||
let capture = XCUIApplication()
|
||||
capture.launchArguments = ["--screenshots"]
|
||||
capture.launch()
|
||||
Thread.sleep(forTimeInterval: 4.0)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let tabBar = capture.tabBars.firstMatch
|
||||
if tabBar.exists && tabBar.buttons["Charts"].exists {
|
||||
tabBar.buttons["Charts"].tap()
|
||||
} else {
|
||||
// iPad main sidebar rows are cells with a static text label
|
||||
capture.staticTexts["Charts"].firstMatch.tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
|
||||
func snap(_ name: String) {
|
||||
let shot = XCTAttachment(screenshot: capture.screenshot())
|
||||
shot.name = name
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
|
||||
// Allocation
|
||||
let allocationRow = capture.staticTexts["Allocation"].firstMatch
|
||||
XCTAssertTrue(allocationRow.waitForExistence(timeout: 5), "Allocation row not found in sidebar")
|
||||
allocationRow.tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap("charts_allocation")
|
||||
XCTAssertTrue(
|
||||
capture.staticTexts["Asset Allocation"].firstMatch.waitForExistence(timeout: 5),
|
||||
"Allocation chart did not load after tapping its sidebar row"
|
||||
)
|
||||
|
||||
// Contributions
|
||||
let contributionsRow = capture.staticTexts["Contributions"].firstMatch
|
||||
XCTAssertTrue(contributionsRow.waitForExistence(timeout: 5), "Contributions row not found")
|
||||
contributionsRow.tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap("charts_contributions")
|
||||
XCTAssertTrue(
|
||||
capture.staticTexts["Monthly avg"].firstMatch.waitForExistence(timeout: 5),
|
||||
"Contributions chart did not load after tapping its sidebar row"
|
||||
)
|
||||
|
||||
// Rolling 12M + period filter
|
||||
let rollingRow = capture.staticTexts["Rolling 12M"].firstMatch
|
||||
XCTAssertTrue(rollingRow.waitForExistence(timeout: 5), "Rolling 12M row not found")
|
||||
rollingRow.tap()
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
snap("charts_rolling_all")
|
||||
let threeMonths = capture.buttons["3M"].firstMatch
|
||||
if threeMonths.waitForExistence(timeout: 3) {
|
||||
threeMonths.tap()
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
snap("charts_rolling_3m")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user