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:
alexandrev-tibco
2026-07-08 22:45:31 +02:00
parent 0e0aec6bb9
commit 7b8dec196d
4 changed files with 272 additions and 424 deletions
+10 -10
View File
@@ -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