diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index bedb216..0b83f41 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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)