banner: unify network+tip into ONE floating overlay (never layout-shifting)

Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
This commit is contained in:
FuelBoard Contributor
2026-08-19 08:17:50 +01:00
parent 57dffb6c5e
commit ba83f9e1c0
+68 -58
View File
@@ -2,6 +2,15 @@ import SwiftUI
import CoreLocation
import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView never pushing layout, always overlaid.
enum AppBanner: Equatable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@@ -459,50 +468,31 @@ struct ContentView: View {
)
}
private var mainContent: some View {
VStack(spacing: 0) {
statusBannerView
rootTabView
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: tipStore.outcome)
}
@ViewBuilder
private var statusBannerView: some View {
if let outcome = tipStore.outcome {
// Tip outcomes ride the SAME banner chrome as the network/offline
// strip auto-dismissed by TipStore, tap anywhere to dismiss.
tipStatusBanner(outcome)
.transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
removal: .opacity))
} else {
switch dataStatus {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBanner(
icon: "wifi.slash",
tint: .orange,
title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
)
.transition(.move(edge: .top).combined(with: .opacity))
case .connectionProblem:
statusBanner(
icon: "wifi.exclamationmark",
tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
)
.transition(.move(edge: .top).combined(with: .opacity))
case .live:
EmptyView()
private var mainContent: some View {
ZStack(alignment: .top) {
rootTabView
if let banner = activeBanner {
// Floating above the tabs: overlays content, never pushes it.
floatingBanner(banner)
.padding(.top, 6)
.transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
removal: .opacity))
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner)
}
private var rootTabView: some View {
@@ -532,27 +522,47 @@ struct ContentView: View {
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
}
/// The offline/connection strip: same chrome as the tip banner, but the
/// tap retries the live fetch from ANY screen no pull gesture needed,
/// so the offline banner isn't trapped on the Stations tab.
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
statusBannerCard(icon: icon, tint: tint, title: title, subtitle: subtitle,
trailingIcon: "arrow.clockwise", accessibilityLabel: accessibilityLabel) {
Task { await refresh(force: true) }
/// ONE floating banner style for all notifications. Network/offline and
/// tip outcomes share the same chrome; only the tap action differs
/// network retry the fetch, tip dismiss (TipStore auto-dismisses too).
@ViewBuilder
private func floatingBanner(_ banner: AppBanner) -> some View {
switch banner {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBannerCard(
icon: "wifi.slash", tint: .orange, title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
) {
Task { await refresh(force: true) }
}
case .connectionProblem:
statusBannerCard(
icon: "wifi.exclamationmark", tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
) {
Task { await refresh(force: true) }
}
case .tip(let outcome):
statusBannerCard(
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message
) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
}
/// A tip outcome, shown through the exact same banner chrome. Unlike the
/// network banner there's no retry tapping dismisses (auto-dismiss in
/// TipStore also fires after ~3.5 s).
private func tipStatusBanner(_ outcome: TipStore.TipOutcome) -> some View {
statusBannerCard(icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
/// Shared status-strip chrome: a tappable card pinned above the tabs.
/// The single floating-banner chrome shared by every notification
/// (network/offline + tip). Overlaid never in flow so it never moves
/// the content beneath it.
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
trailingIcon: String, accessibilityLabel: String,
action: @escaping () -> Void) -> some View {
@@ -586,9 +596,9 @@ struct ContentView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
)
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)