From bb4060ea66907c83344f1213e4baa4f6d3e666be Mon Sep 17 00:00:00 2001 From: variablefate Date: Tue, 5 May 2026 18:07:52 -0700 Subject: [PATCH 1/2] feat(ui): global offline pill at top of RootView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tab views (DriversTab, HistoryTab, RideTab) check `isRelayConnected()` independently and render their own inline offline UI, but there was no global "you're offline" indicator. Users had to navigate to a specific tab to spot connectivity issues, and the publish-failure banner from #95 lacked context — "couldn't reach the relay" reads differently when the user can also see at a glance that they're offline. Add `ConnectivityPill` at the top of RootView, above the publish-failure banner. The pill renders only when `isRelayConnected()` returns false and the auth state is past `.loading`. Tap presents the existing `ConnectivitySheet` for diagnostics + manual reconnect. Per-tab inline offline UI is intentionally retained — the pill communicates global state, the per-tab UI communicates per-tab consequences. Closes #97 item 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- RoadFlare/RoadFlare/Views/RootView.swift | 15 ++-- .../Views/Shared/ConnectivityPill.swift | 76 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift diff --git a/RoadFlare/RoadFlare/Views/RootView.swift b/RoadFlare/RoadFlare/Views/RootView.swift index 4bb82fe..495f46a 100644 --- a/RoadFlare/RoadFlare/Views/RootView.swift +++ b/RoadFlare/RoadFlare/Views/RootView.swift @@ -7,6 +7,8 @@ struct RootView: View { var body: some View { VStack(spacing: 0) { + ConnectivityPill() + if case .failed(let domain) = appState.onboardingPublishStatus { OnboardingPublishFailureBanner(domain: domain) { appState.retryOnboardingPublish() @@ -19,12 +21,13 @@ struct RootView: View { } // Each `authStateContent` view paints its own `Color.rfSurface // .ignoresSafeArea()` background, but that only extends adjacent to - // its own frame — when the banner is showing, the strip above the - // banner (status bar / Dynamic Island zone) is no longer adjacent - // to the auth-state content, so the system default would bleed - // through. Painting `rfSurface` behind the whole VStack keeps the - // status-bar zone on-brand in both banner-visible and banner- - // hidden states. + // its own frame — when one of the top-of-stack views (connectivity + // pill, publish-failure banner) is showing, the strip above the + // top view (status bar / Dynamic Island zone) is no longer + // adjacent to the auth-state content, so the system default would + // bleed through. Painting `rfSurface` behind the whole VStack keeps + // the status-bar zone on-brand regardless of which top-of-stack + // views are visible. .background(Color.rfSurface.ignoresSafeArea()) .animation(.easeInOut(duration: 0.2), value: appState.onboardingPublishStatus) } diff --git a/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift b/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift new file mode 100644 index 0000000..4f47a07 --- /dev/null +++ b/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift @@ -0,0 +1,76 @@ +import SwiftUI +import RoadFlareCore + +/// Top-of-RootView pill shown whenever the relay is not reachable. Stacks +/// above the onboarding publish-failure banner (ADR-0016) so the user sees +/// the connection state first, then the specific consequence. +/// +/// Tapping the pill presents `ConnectivitySheet` for diagnostics + manual +/// reconnect — the same sheet the per-tab toolbar buttons already open. The +/// pill is global so the user does not have to be on a specific tab to spot +/// connectivity issues. +/// +/// Hidden during `.loading` because the relay manager isn't configured yet +/// at that point; the launch screen would otherwise flash the pill for a +/// fraction of a second on every cold start. Per-tab inline offline UI +/// (e.g. the empty states in `RideTab`/`DriversTab`/`HistoryTab`) is +/// intentionally retained — those communicate per-tab consequences, this +/// pill communicates global state. +struct ConnectivityPill: View { + @Environment(AppState.self) private var appState + @State private var isOffline = false + @State private var showSheet = false + + /// Polling cadence matches `ConnectivitySheet`'s own self-refresh loop. + private static let pollIntervalSeconds: UInt64 = 5 + + var body: some View { + ZStack { + if isOffline && appState.authState != .loading { + pillBar + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: isOffline) + .sheet(isPresented: $showSheet) { ConnectivitySheet() } + .task { await pollLoop() } + } + + private var pillBar: some View { + Button { + showSheet = true + } label: { + HStack(spacing: 10) { + Image(systemName: "wifi.slash") + .foregroundColor(Color.rfOffline) + .font(.system(size: 14, weight: .semibold)) + .accessibilityHidden(true) + + Text("You're offline") + .font(RFFont.title(13)) + .foregroundColor(Color.rfOnSurface) + + Text("Tap for details") + .font(RFFont.caption(12)) + .foregroundColor(Color.rfOnSurfaceVariant) + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.rfSurfaceContainer) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel("You're offline. Tap for connectivity details.") + .accessibilityAddTraits(.isButton) + } + + private func pollLoop() async { + while !Task.isCancelled { + isOffline = !(await appState.isRelayConnected()) + try? await Task.sleep(nanoseconds: Self.pollIntervalSeconds * 1_000_000_000) + } + } +} From 88538502128c11d3991905aca60a4c634b5e0104 Mon Sep 17 00:00:00 2001 From: variablefate Date: Tue, 5 May 2026 18:46:30 -0700 Subject: [PATCH 2/2] fix(ui): align pill polling to 10s + animate cold-start visibility correctly Two follow-ups from code review on PR #100: 1. Poll cadence 5s -> 10s: per-tab views (RideTab, DriversTab, HistoryTab) all use 10s as the persistent connection-monitoring cadence. ConnectivitySheet polls at 5s because it's a modal the user is actively looking at. The pill is a background indicator, so the per-tab cadence is the correct match. 2. Animate visibility on a computed `shouldShow` rather than `isOffline` alone. On the cold-start offline path, `isOffline` flips to `true` while `authState == .loading`; later `authState` exits `.loading` while `isOffline` is unchanged. Keying the animation on `isOffline` skipped the slide-in transition because the animation key didn't change at the visible-transition moment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/Shared/ConnectivityPill.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift b/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift index 4f47a07..a72e719 100644 --- a/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift +++ b/RoadFlare/RoadFlare/Views/Shared/ConnectivityPill.swift @@ -21,17 +21,31 @@ struct ConnectivityPill: View { @State private var isOffline = false @State private var showSheet = false - /// Polling cadence matches `ConnectivitySheet`'s own self-refresh loop. - private static let pollIntervalSeconds: UInt64 = 5 + /// 10s matches the persistent per-tab polling cadence in `RideTab`, + /// `DriversTab`, and `HistoryTab`. `ConnectivitySheet` polls faster (5s) + /// because it's a modal the user is actively looking at; the pill is a + /// background indicator and doesn't need that responsiveness. + private static let pollIntervalSeconds: UInt64 = 10 + + /// Computed visibility — combines the polled flag with the auth-state + /// gate. Animating on this (rather than `isOffline` alone) ensures the + /// pill animates in correctly on the cold-start path where `isOffline` + /// flips to `true` while still in `.loading`, then `authState` exits + /// `.loading` later: keying on `isOffline` alone would skip the + /// animation since `isOffline` didn't change at the visible-transition + /// moment. + private var shouldShow: Bool { + isOffline && appState.authState != .loading + } var body: some View { ZStack { - if isOffline && appState.authState != .loading { + if shouldShow { pillBar .transition(.move(edge: .top).combined(with: .opacity)) } } - .animation(.easeInOut(duration: 0.2), value: isOffline) + .animation(.easeInOut(duration: 0.2), value: shouldShow) .sheet(isPresented: $showSheet) { ConnectivitySheet() } .task { await pollLoop() } }