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:
+62
-52
@@ -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)
|
||||
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))
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
/// 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) }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
Reference in New Issue
Block a user