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;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -739,7 +739,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -774,7 +774,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 48;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ enum ScreenshotMode {
|
|||||||
d.set(false, forKey: "lockOnBackground")
|
d.set(false, forKey: "lockOnBackground")
|
||||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||||
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
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
|
/// Seed demo data (no-op if the store already has sources). Called once Core Data
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ struct ChartsContainerView: View {
|
|||||||
iPhoneChartsLayout
|
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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) { filterMenu }
|
||||||
if horizontalSizeClass != .regular {
|
if horizontalSizeClass != .regular {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
ToolbarItem(placement: .navigationBarTrailing) { shareButton }
|
||||||
}
|
}
|
||||||
@@ -51,40 +56,24 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
private var iPadChartsLayout: some View {
|
private var iPadChartsLayout: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Left sidebar: grouped chart-type tiles + contextual filters
|
// Left sidebar: native List selection (system hit-testing, keyboard
|
||||||
ScrollView {
|
// navigation, pointer effects) — replaces the custom tile grid whose
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
// Buttons were unreliable inside NavigationSplitView.
|
||||||
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
List(selection: sidebarSelection) {
|
||||||
let types = group.types.filter { showForecast || $0 != .prediction }
|
ForEach(Self.chartGroups, id: \.titleKey) { group in
|
||||||
if !types.isEmpty {
|
let types = group.types.filter { showForecast || $0 != .prediction }
|
||||||
Text(String(localized: String.LocalizationValue(group.titleKey)))
|
if !types.isEmpty {
|
||||||
.font(.caption.weight(.semibold))
|
Section(String(localized: String.LocalizationValue(group.titleKey))) {
|
||||||
.foregroundColor(.secondary)
|
ForEach(types) { chartType in
|
||||||
.padding(.horizontal, 16)
|
sidebarRow(chartType)
|
||||||
.padding(.top, 14)
|
.tag(chartType)
|
||||||
.padding(.bottom, 6)
|
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible())], spacing: 8) {
|
|
||||||
ForEach(types) { chartType in
|
|
||||||
iPadChartTypeTile(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)
|
.frame(width: 248)
|
||||||
.background(Color(.systemBackground))
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -128,6 +117,141 @@ struct ChartsContainerView: View {
|
|||||||
("chart_group_forecast", [.prediction, .simulator])
|
("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)
|
// MARK: - KPI Header (iPad)
|
||||||
|
|
||||||
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
|
/// Chart-specific metrics above the chart. Each chart type surfaces the KPIs
|
||||||
@@ -299,7 +423,10 @@ struct ChartsContainerView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if !ranges.isEmpty && viewModel.selectedChartType != .performance {
|
if viewModel.selectedChartType == .performance {
|
||||||
|
performanceSlider
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
} else if !ranges.isEmpty {
|
||||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||||
ForEach(ranges) { range in
|
ForEach(ranges) { range in
|
||||||
Text(range.rawValue).tag(range)
|
Text(range.rawValue).tag(range)
|
||||||
@@ -313,6 +440,24 @@ struct ChartsContainerView: View {
|
|||||||
.padding(.horizontal, 4)
|
.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 {
|
private var shareButton: some View {
|
||||||
Button {
|
Button {
|
||||||
shareCurrentChart()
|
shareCurrentChart()
|
||||||
@@ -366,14 +511,13 @@ struct ChartsContainerView: View {
|
|||||||
AppBackground()
|
AppBackground()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
chartTypeSelector
|
|
||||||
if !viewModel.isPremium {
|
if !viewModel.isPremium {
|
||||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filtersSection
|
iPhonePeriodControl
|
||||||
chartContent
|
chartContent
|
||||||
if viewModel.hiddenHistoryMonths > 0 {
|
if viewModel.hiddenHistoryMonths > 0 {
|
||||||
lockedHistoryTeaser
|
lockedHistoryTeaser
|
||||||
@@ -385,44 +529,24 @@ struct ChartsContainerView: View {
|
|||||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iPad Chart Type Tile
|
/// Stocks-style segmented period control above the chart (the chart switcher
|
||||||
|
/// lives in the title menu, filters in the toolbar).
|
||||||
private func iPadChartTypeTile(_ chartType: ChartsViewModel.ChartType) -> some View {
|
@ViewBuilder
|
||||||
let isSelected = viewModel.selectedChartType == chartType
|
private var iPhonePeriodControl: some View {
|
||||||
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
|
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||||||
return Button {
|
if viewModel.selectedChartType == .performance {
|
||||||
viewModel.selectChart(chartType)
|
performanceSlider
|
||||||
} label: {
|
.padding(12)
|
||||||
VStack(spacing: 6) {
|
.background(Color(.systemBackground))
|
||||||
ZStack(alignment: .topTrailing) {
|
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||||
Image(systemName: chartType.icon)
|
} else if !ranges.isEmpty {
|
||||||
.font(.system(size: 18))
|
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||||
.foregroundColor(isSelected ? .white : .appPrimary)
|
ForEach(ranges) { range in
|
||||||
.frame(maxWidth: .infinity)
|
Text(range.rawValue).tag(range)
|
||||||
.frame(height: 34)
|
|
||||||
if isPremiumLocked {
|
|
||||||
Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
.pickerStyle(.segmented)
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(isSelected ? Color.appPrimary : Color(.systemGray6))
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(chartType.rawValue)
|
|
||||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared
|
// MARK: - Shared
|
||||||
@@ -437,306 +561,6 @@ struct ChartsContainerView: View {
|
|||||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
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 {
|
private func periodLabel(_ months: Int) -> String {
|
||||||
if months < 12 {
|
if months < 12 {
|
||||||
return "\(months)M"
|
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
|
// MARK: - Evolution Chart View
|
||||||
|
|
||||||
struct EvolutionChartView: 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
|
/// Captures full-screen screenshots of the main tabs with demo data for App Store
|
||||||
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
|
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
|
||||||
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
|
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
|
||||||
|
|||||||
Reference in New Issue
Block a user