feat(bar): swipeable profile carousel and condensed alerts (#1603)

Closes #1602
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-24 18:44:53 -04:00
1 parent 0d20a102bd
commit d7933c6243
2 files changed
+284 -59

No files matched your search

+283 -58
View File
@@ -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
}
}
}
+1 -1
View File
@@ -1 +1 @@
1.9.1
1.9.2