From d7933c6243b7593eed524b6aa6c9c977f1e16eea Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:44:53 -0400 Subject: [PATCH] feat(bar): swipeable profile carousel and condensed alerts (#1603) Closes #1602 --- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 341 +++++++++++++++--- macos-bar/VERSION | 2 +- 2 files changed, 284 insertions(+), 59 deletions(-) diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index b5dd9396..1f0ea18f 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -43,6 +43,14 @@ struct BarMenuView: View { // Per-provider carousel position: provider -> selected profile row id. Each // provider has its own profile carousel, so selection is tracked per provider. @State private var selectedProfileByProvider: [String: String] = [:] + /// Live horizontal drag translation per provider while a swipe is in progress. + /// Reset to 0 (and committed to a page change) on drag release. Keyed by + /// provider so each carousel tracks its own in-flight swipe independently. + @State private var dragByProvider: [String: CGFloat] = [:] + /// Whether the Alerts section is expanded to show every alert. Collapsed by + /// default: only the most-severe few render, with a "+N more" toggle, so a + /// burst of conditions never buries the cockpit under a wall of rows. + @State private var alertsExpanded = false // MARK: - Screen cap @@ -91,14 +99,10 @@ struct BarMenuView: View { // (2) ALERTS — urgent quota crossings surface above accounts. // Spend-cap alerts are opt-in OFF by default, so by default only - // quota/reauth/cooldown conditions appear here. + // quota/reauth/cooldown conditions appear here. Deduped, severity- + // ranked, compact, and collapsed past a few — see alertsSection. if !viewModel.activeAlerts.isEmpty { - VStack(alignment: .leading, spacing: 8) { - SectionLabel("Alerts") - ForEach(viewModel.activeAlerts) { alert in - AlertRow(alert: alert) - } - } + alertsSection } // (3) SUBSCRIPTIONS — the dominant section, opens here. @@ -177,13 +181,15 @@ struct BarMenuView: View { // Disarm quit on every popover open so a stale armed state never persists. quitArmed = false // Reset each provider's carousel to its first profile on every open — KISS, - // no persistence needed. + // no persistence needed. Clear any in-flight drag and re-collapse alerts. selectedProfileByProvider = [:] + dragByProvider = [:] + alertsExpanded = false } } /// "Update available" banner. Shown when `viewModel.updateAvailable` is true. - /// Styled to match the existing AlertRow / ErrorBanner patterns (tinted + /// Styled to match the existing CompactAlertRow / ErrorBanner patterns (tinted /// background card, section label, borderless button). @ViewBuilder private var updateBanner: some View { VStack(alignment: .leading, spacing: 8) { @@ -277,37 +283,9 @@ struct BarMenuView: View { onRefresh: { viewModel.forceRefresh() }) } } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(rows) { row in - BarSubscriptionCard( - row: row, isParked: row.paused, - onRefresh: { viewModel.forceRefresh() }) - // Each card spans exactly the horizontal scroll viewport so - // .paging snaps to one whole card per page. A fixed width - // wider than the viewport (the 360 popover minus the content - // VStack's 14pt padding leaves ~332pt) drifts a few points on - // every page and leaves the card stuck off-center after - // paging forward and back. containerRelativeFrame keeps the - // card width == viewport width, so paging stays aligned and - // the card is centered like the single-profile card. - .containerRelativeFrame(.horizontal) - .id(row.id) - } - } - // scrollTargetLayout on the HStack (the paging container) snaps to - // each profile card on .paging behavior. - .scrollTargetLayout() - } - .scrollTargetBehavior(.paging) - .scrollPosition(id: Binding( - get: { selectedProfileByProvider[prov] ?? rows.first?.id }, - set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id })) - .frame(height: carouselHeight(rows)) + profileCarousel(prov: prov, rows: rows) // Page controls — clickable prev/next arrows + dots so the carousel - // is navigable by MOUSE (click), not just by trackpad swipe. - // Programmatically setting the scrollPosition binding pages the - // ScrollView; withAnimation gives the same feel as a swipe. + // is also navigable by MOUSE click, not only by drag/swipe. carouselControls(prov: prov, rows: rows) } } @@ -372,9 +350,77 @@ struct BarMenuView: View { } } + /// Swipeable profile pager: one full-width card visible at a time, an HStack of + /// the provider's cards offset to the current page. Two complementary inputs + /// move between pages so no device is left out: + /// - a `DragGesture` for a mouse/trackpad press-drag (the previous ScrollView + /// never paged on a plain mouse click-drag), committing on release past a + /// 20% threshold, with the ends rubber-banding so an over-drag resists; + /// - a `CarouselScrollPager` overlay that turns a non-clicking horizontal + /// trackpad / Magic Mouse swipe (delivered as scroll-wheel events, which a + /// DragGesture does not see) into a page step. + /// No ScrollView, so there is no paging drift and the card is always centered. + @ViewBuilder private func profileCarousel(prov: String, rows: [BarSummaryRow]) -> some View { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + GeometryReader { geo in + let pageWidth = geo.size.width + HStack(spacing: 0) { + ForEach(rows) { row in + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + .frame(width: pageWidth) + } + } + .offset(x: -CGFloat(curIdx) * pageWidth + (dragByProvider[prov] ?? 0)) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { value in + // Rubber-band at the ends: an over-drag past the first/last card moves + // at a third the rate so it springs back instead of revealing a gap. + let raw = value.translation.width + let atStart = curIdx == 0 && raw > 0 + let atEnd = curIdx == rows.count - 1 && raw < 0 + dragByProvider[prov] = (atStart || atEnd) ? raw / 3 : raw + } + .onEnded { value in + // Commit a page change when the swipe passes 20% of the page width; + // otherwise snap back to the current card. + let threshold = pageWidth * 0.2 + var newIdx = curIdx + if value.translation.width <= -threshold { newIdx = min(curIdx + 1, rows.count - 1) } + else if value.translation.width >= threshold { newIdx = max(curIdx - 1, 0) } + withAnimation(.easeOut(duration: 0.2)) { + dragByProvider[prov] = 0 + selectedProfileByProvider[prov] = rows[newIdx].id + } + } + ) + // Trackpad / Magic Mouse horizontal swipe (scroll-wheel events) → page step. + // Transparent to clicks and to the DragGesture; only observes scroll. + .overlay( + CarouselScrollPager { step in page(prov: prov, by: step, rows: rows) } + ) + } + .frame(height: carouselHeight(rows)) + .clipped() // hide the neighbouring cards that sit outside the page viewport + } + + /// Step the given provider's carousel by ±1 page, clamped to the ends. Used by + /// the horizontal scroll-swipe overlay; the arrows/dots call `selectPage` + /// directly. + private func page(prov: String, by step: Int, rows: [BarSummaryRow]) { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + let newIdx = min(max(curIdx + step, 0), rows.count - 1) + if newIdx != curIdx { selectPage(prov, rows[newIdx].id) } + } + /// Prev/next arrows + clickable dots for a provider's profile carousel, so it - /// is navigable by MOUSE (click), not only by trackpad swipe. Selecting a page - /// sets the scrollPosition binding, which scrolls the ScrollView to that card. + /// is navigable by MOUSE click, not only by drag/swipe. Selecting a page sets + /// `selectedProfileByProvider`, which animates the pager offset to that card. @ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View { let currentId = selectedProfileByProvider[prov] ?? rows.first?.id let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 @@ -430,6 +476,92 @@ struct BarMenuView: View { return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0) } + // MARK: Alerts + + /// One displayed alert after de-duplication: the representative notification + /// plus how many identical conditions it stands for (e.g. the same "ck needs + /// re-authentication" firing on two surfaces collapses to one row with ×2). + private struct GroupedAlert: Identifiable { + let id: String + let alert: BarNotification + let count: Int + } + + /// De-duplicate alerts by their visible text and rank by severity so the most + /// actionable condition leads. Two alerts that render identically (same title + + /// body) collapse into one group with a count, killing the "ck reauth" / + /// "ck reauth" / "ck paused" / "ck paused" repetition seen with multi-surface + /// profiles. + private func groupedAlerts(_ alerts: [BarNotification]) -> [GroupedAlert] { + var order: [String] = [] + var byKey: [String: (alert: BarNotification, count: Int)] = [:] + for a in alerts { + let key = a.title + "\u{1F}" + a.body + if let hit = byKey[key] { + byKey[key] = (hit.alert, hit.count + 1) + } else { + byKey[key] = (a, 1) + order.append(key) + } + } + return order + .map { GroupedAlert(id: $0, alert: byKey[$0]!.alert, count: byKey[$0]!.count) } + .sorted { alertSeverityRank($0.alert.kind) < alertSeverityRank($1.alert.kind) } + } + + /// Severity order for alert ranking: reauth (account unusable) first, spend + /// caps next, then quota, then the soft paused/cooldown note. + private func alertSeverityRank(_ kind: BarAlertKind) -> Int { + switch kind { + case .reauthNeeded: return 0 + case .dailySpendAbove, .monthSpendAbove: return 1 + case .quotaRemainingBelow: return 2 + case .accountCooldownOrPaused: return 3 + } + } + + /// Calm, compact alerts: a labelled header with a total count, then a few + /// single-line rows (most-severe first). Collapsed past `collapsedCap` behind a + /// "+N more" toggle so a burst of conditions never floods the popover. Replaces + /// the previous stack of tall two-line cards. + @ViewBuilder private var alertsSection: some View { + let groups = groupedAlerts(viewModel.activeAlerts) + let collapsedCap = 3 + let overflow = groups.count - collapsedCap + let visible = alertsExpanded ? groups : Array(groups.prefix(collapsedCap)) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + SectionLabel("Alerts") + Text("\(groups.count)") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.18), in: Capsule()) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + ForEach(visible) { g in + CompactAlertRow(alert: g.alert, count: g.count) + } + if overflow > 0 { + Button { + withAnimation(.easeInOut(duration: 0.15)) { alertsExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Image(systemName: alertsExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + Text(alertsExpanded ? "Show less" : "\(overflow) more") + .font(.caption2) + } + .foregroundStyle(.secondary) + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) @@ -789,33 +921,38 @@ struct ErrorBanner: View { } } -/// One in-dropdown alert row. Mirrors a delivered notification so the conditions -/// are visible even when system notifications are denied. The icon is keyed off -/// the alert kind so each rule reads at a glance. -struct AlertRow: View { +/// Compact, single-line alert row used by the condensed Alerts section. One +/// glanceable line — kind icon + the self-describing body (the title is dropped +/// as redundant with the icon) + an optional ×N when several identical +/// conditions were merged. The tint is softer than the old `AlertRow` card so a +/// list of them reads as informative, not alarming. +struct CompactAlertRow: View { @Environment(\.barTheme) private var theme let alert: BarNotification + let count: Int var body: some View { - HStack(alignment: .top, spacing: 6) { + HStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(tint) - .font(.caption) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 1) { - Text(alert.title) - .font(.caption.weight(.medium)) - Text(alert.body) - .font(.caption2) + .font(.caption2) + .frame(width: 12) + Text(alert.body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if count > 1 { + Text("×\(count)") + .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) - .lineLimit(2) } Spacer(minLength: 0) } - .padding(.vertical, 5) + .padding(.vertical, 4) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) } private var icon: String { @@ -828,8 +965,6 @@ struct AlertRow: View { } private var tint: Color { - // Themed: quota warnings take the brand accent, reauth the critical band, - // so alert chips match the rest of the dropdown on both plates. switch alert.kind { case .quotaRemainingBelow: return theme.accent case .dailySpendAbove, .monthSpendAbove: return theme.accent @@ -868,3 +1003,93 @@ struct Chip: View { .foregroundStyle(textColor) } } + +/// Adds horizontal trackpad / Magic Mouse swipe paging to the profile carousel. +/// SwiftUI's `DragGesture` handles a mouse or trackpad press-drag, but a +/// non-clicking two-finger swipe arrives as scroll-wheel events it never sees. +/// +/// This hosts a transparent AppKit anchor view (click- and drag-transparent via +/// a nil `hitTest`, so it never blocks the cards' buttons or the DragGesture) and +/// a local scroll-wheel monitor scoped to that view's on-screen frame. A +/// predominantly horizontal scroll pages once per gesture; a vertical scroll is +/// passed straight through so the popover still scrolls. If the frame math ever +/// fails to match, the worst case is that scroll-swipe simply does nothing — +/// drag and the arrows/dots still work — so the failure mode is benign. +struct CarouselScrollPager: NSViewRepresentable { + /// Called with +1 (next) or -1 (previous) when a horizontal swipe commits. + let onPage: (Int) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onPage: onPage) } + + func makeNSView(context: Context) -> NSView { + let view = PassthroughView() + context.coordinator.attach(to: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.onPage = onPage + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.detach() + } + + /// Anchor view that is transparent to all mouse hit-testing, so clicks and the + /// SwiftUI DragGesture pass through to the cards beneath it. + final class PassthroughView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + /// Owns the local scroll-wheel monitor and the per-gesture accumulator. + final class Coordinator { + var onPage: (Int) -> Void + private weak var view: NSView? + private var monitor: Any? + private var accumulated: CGFloat = 0 + private var firedThisGesture = false + private let threshold: CGFloat = 40 + + init(onPage: @escaping (Int) -> Void) { self.onPage = onPage } + + func attach(to view: NSView) { + self.view = view + monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + self?.handle(event) ?? event + } + } + + func detach() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + } + + /// Return nil to consume a horizontal swipe inside the carousel; return the + /// event unchanged otherwise so vertical popover scroll is never swallowed. + private func handle(_ event: NSEvent) -> NSEvent? { + guard let view, let window = view.window, event.window === window else { return event } + let frameInWindow = view.convert(view.bounds, to: nil) + guard frameInWindow.contains(event.locationInWindow) else { return event } + + let dx = event.scrollingDeltaX + let dy = event.scrollingDeltaY + guard abs(dx) > abs(dy) else { return event } // vertical → let the popover scroll + + if event.phase.contains(.began) || event.momentumPhase.contains(.began) { + accumulated = 0 + firedThisGesture = false + } + accumulated += dx + if !firedThisGesture && abs(accumulated) >= threshold { + firedThisGesture = true + // Natural scrolling: content moving left (negative dx) advances to next. + onPage(accumulated < 0 ? 1 : -1) + } + if event.phase.contains(.ended) || event.momentumPhase.contains(.ended) { + accumulated = 0 + firedThisGesture = false + } + return nil + } + } +} diff --git a/macos-bar/VERSION b/macos-bar/VERSION index 9ab8337f..8fdcf386 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.9.1 +1.9.2