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:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user