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:
+68
-58
@@ -2,6 +2,15 @@ import SwiftUI
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import WidgetKit
|
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 {
|
struct ContentView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -459,50 +468,31 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainContent: some View {
|
/// The one banner to show right now. Network/offline and tip outcomes all
|
||||||
VStack(spacing: 0) {
|
/// funnel here and render through the SAME floating chrome. A transient
|
||||||
statusBannerView
|
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
||||||
rootTabView
|
/// 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 mainContent: some View {
|
||||||
private var statusBannerView: some View {
|
ZStack(alignment: .top) {
|
||||||
if let outcome = tipStore.outcome {
|
rootTabView
|
||||||
// Tip outcomes ride the SAME banner chrome as the network/offline
|
if let banner = activeBanner {
|
||||||
// strip — auto-dismissed by TipStore, tap anywhere to dismiss.
|
// Floating above the tabs: overlays content, never pushes it.
|
||||||
tipStatusBanner(outcome)
|
floatingBanner(banner)
|
||||||
.transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
|
.padding(.top, 6)
|
||||||
removal: .opacity))
|
.transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
|
||||||
} else {
|
removal: .opacity))
|
||||||
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 {
|
private var rootTabView: some View {
|
||||||
@@ -532,27 +522,47 @@ struct ContentView: View {
|
|||||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offline/connection strip: same chrome as the tip banner, but the
|
/// ONE floating banner style for all notifications. Network/offline and
|
||||||
/// tap retries the live fetch from ANY screen — no pull gesture needed,
|
/// tip outcomes share the same chrome; only the tap action differs —
|
||||||
/// so the offline banner isn't trapped on the Stations tab.
|
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||||||
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
@ViewBuilder
|
||||||
statusBannerCard(icon: icon, tint: tint, title: title, subtitle: subtitle,
|
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||||||
trailingIcon: "arrow.clockwise", accessibilityLabel: accessibilityLabel) {
|
switch banner {
|
||||||
Task { await refresh(force: true) }
|
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
|
/// The single floating-banner chrome shared by every notification
|
||||||
/// network banner there's no retry — tapping dismisses (auto-dismiss in
|
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||||||
/// TipStore also fires after ~3.5 s).
|
/// the content beneath it.
|
||||||
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.
|
|
||||||
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||||
trailingIcon: String, accessibilityLabel: String,
|
trailingIcon: String, accessibilityLabel: String,
|
||||||
action: @escaping () -> Void) -> some View {
|
action: @escaping () -> Void) -> some View {
|
||||||
@@ -586,9 +596,9 @@ struct ContentView: View {
|
|||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 6)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
|||||||
Reference in New Issue
Block a user