diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 9b707f0..5a90a44 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -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; diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index c4cbfb1..da19b97 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -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 diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 251e5ff..b7c91e4 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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 { + 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 { + Binding( + get: { viewModel.selectedCategory }, + set: { newValue in + viewModel.selectedCategory = newValue + viewModel.selectedSource = nil + } + ) + } + + private func sourceToggle(_ source: InvestmentSource) -> Binding { + 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(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 { diff --git a/PortfolioJournalUITests/PortfolioJournalUITests.swift b/PortfolioJournalUITests/PortfolioJournalUITests.swift index 456e57a..b37fa02 100644 --- a/PortfolioJournalUITests/PortfolioJournalUITests.swift +++ b/PortfolioJournalUITests/PortfolioJournalUITests.swift @@ -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