diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index b1a130e..700b440 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -486,12 +486,12 @@ struct ContentView: View { ZStack(alignment: .top) { rootTabView if let banner = activeBanner { - // Floating just below the nav bar: overlays content (never - // pushes it) and stays clear of the iOS 26 nav/toolbar - // Liquid Glass chrome so a tap on the banner can't also - // trigger the control behind it. + // Floating near the top of the screen, over the nav area — + // overlays content (never pushes it) and sits above the + // main content so it doesn't cover or block the list/pill + // beneath it. floatingBanner(banner) - .padding(.top, geo.safeAreaInsets.top + 52) + .padding(.top, geo.safeAreaInsets.top + 10) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), // Slide up + fade on dismiss/timeout (one style for all). diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index 94fb3fe..3b75ffe 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -245,19 +245,27 @@ struct OnboardingView: View { ) { if prompter.locationDenied { openSettings() + } else if prompter.locationGranted { + page = 2 // already decided — just move on } else { - // The prompt fires on LEAVING this page (onChange(of: - // page), forward advance only) — the description is on - // screen until the user moves on. - page = 2 + // Fire the system prompt NOW, while this page is still on + // screen (after it's been read) and BEFORE advancing — so + // the prompt never covers the next page's animation. Auto- + // advance on grant moves us on once the user responds; + // denying leaves the page's "Open Settings" path. + prompter.requestLocation() } } case 2: primaryButton( prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications") ) { - // Prompt fires on leaving this page (same rule as Location). - page = 3 + if prompter.notificationsDenied || prompter.notificationsGranted { + page = 3 // decided either way — move on + } else { + // Same rule as Location: prompt now, before advancing. + prompter.requestNotifications() + } } case 3: // No permission on this page — prices need none. Straight on. diff --git a/Shared/FuelHistory.swift b/Shared/FuelHistory.swift index 77114b6..67197bc 100644 --- a/Shared/FuelHistory.swift +++ b/Shared/FuelHistory.swift @@ -216,10 +216,18 @@ enum FuelHistoryStore { // MARK: Network /// The mirror pointer — used for the empty-state hint ("first snapshot - /// landed …"). Non-fatal: nil just means no hint. - static func fetchLatest(base: URL = mirrorBase, + /// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses + /// 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? { - 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, let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else { return nil diff --git a/Shared/MirrorFuelProvider.swift b/Shared/MirrorFuelProvider.swift index e81b477..32184b2 100644 --- a/Shared/MirrorFuelProvider.swift +++ b/Shared/MirrorFuelProvider.swift @@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding { if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data { data = cached } else { - let (fetched, response) = try await URLSession.shared.data( - from: FuelHistoryStore.historyFileURL(day: day, base: baseURL) - ) + // Bypass the HTTP cache + short timeout: an offline refresh must + // 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 { throw FuelProviderError.mirrorUnavailable }