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