banner: top-floating; fix offline-trigger cache; onboarding prompt timing

- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
This commit is contained in:
FuelBoard Contributor
2026-08-19 10:17:24 +01:00
parent 8936643e0e
commit cfd435d12a
4 changed files with 37 additions and 17 deletions
+5 -5
View File
@@ -486,12 +486,12 @@ struct ContentView: View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
rootTabView rootTabView
if let banner = activeBanner { if let banner = activeBanner {
// Floating just below the nav bar: overlays content (never // Floating near the top of the screen, over the nav area
// pushes it) and stays clear of the iOS 26 nav/toolbar // overlays content (never pushes it) and sits above the
// Liquid Glass chrome so a tap on the banner can't also // main content so it doesn't cover or block the list/pill
// trigger the control behind it. // beneath it.
floatingBanner(banner) floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 52) .padding(.top, geo.safeAreaInsets.top + 10)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity), insertion: .move(edge: .top).combined(with: .opacity),
// Slide up + fade on dismiss/timeout (one style for all). // Slide up + fade on dismiss/timeout (one style for all).
+14 -6
View File
@@ -245,19 +245,27 @@ struct OnboardingView: View {
) { ) {
if prompter.locationDenied { if prompter.locationDenied {
openSettings() openSettings()
} else if prompter.locationGranted {
page = 2 // already decided just move on
} else { } else {
// The prompt fires on LEAVING this page (onChange(of: // Fire the system prompt NOW, while this page is still on
// page), forward advance only) the description is on // screen (after it's been read) and BEFORE advancing so
// screen until the user moves on. // the prompt never covers the next page's animation. Auto-
page = 2 // advance on grant moves us on once the user responds;
// denying leaves the page's "Open Settings" path.
prompter.requestLocation()
} }
} }
case 2: case 2:
primaryButton( primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications") prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) { ) {
// Prompt fires on leaving this page (same rule as Location). if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 page = 3 // decided either way move on
} else {
// Same rule as Location: prompt now, before advancing.
prompter.requestNotifications()
}
} }
case 3: case 3:
// No permission on this page prices need none. Straight on. // No permission on this page prices need none. Straight on.
+11 -3
View File
@@ -216,10 +216,18 @@ enum FuelHistoryStore {
// MARK: Network // MARK: Network
/// The mirror pointer used for the empty-state hint ("first snapshot /// The mirror pointer used for the empty-state hint ("first snapshot
/// landed "). Non-fatal: nil just means no hint. /// landed "). Non-fatal: nil just means no hint. Deliberately bypasses
static func fetchLatest(base: URL = mirrorBase, /// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
/// really fails instead of silently re-serving a cached pointer as a
/// success otherwise "no network" would look like fresh data and the
/// offline banner would never fire. A short timeout surfaces the failure
/// (and the banner) quickly on a connected-but-dead network.
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
session: URLSession = .shared) async -> MirrorLatest? { session: URLSession = .shared) async -> MirrorLatest? {
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)), var req = URLRequest(url: latestFileURL(base: base))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = timeout
guard let (data, response) = try? await session.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200, (response as? HTTPURLResponse)?.statusCode == 200,
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else { let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
return nil return nil
+7 -3
View File
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data { if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
data = cached data = cached
} else { } else {
let (fetched, response) = try await URLSession.shared.data( // Bypass the HTTP cache + short timeout: an offline refresh must
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL) // FAIL ( offline banner), never re-serve a stale cached dump as
) // a "successful" live fetch.
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = 10
let (fetched, response) = try await URLSession.shared.data(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FuelProviderError.mirrorUnavailable throw FuelProviderError.mirrorUnavailable
} }