Merge main into feature/watch-app
Bring watch branch up to date with main (banner fade, onboarding timing, Live Activity full-width layout). Resolved the bundled fuel dump data-refresh conflict by taking main's newer snapshot (data_updated 2026-08-17T15:01, 8028 stations).
This commit is contained in:
+191
-63
@@ -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: Hashable {
|
||||
case offlineDump(date: String)
|
||||
case connectionProblem
|
||||
case tip(TipStore.TipOutcome)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -32,6 +41,24 @@ struct ContentView: View {
|
||||
private var effectiveAlertsRadiusKM: Double {
|
||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||
}
|
||||
|
||||
private func installLocationUpdateHook() {
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||||
if alertsEnabled || liveActivityEnabled {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var statusMessage = ""
|
||||
/// What data is on screen, driving which (if any) status banner shows
|
||||
@@ -52,6 +79,20 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var locationManager = LocationManager()
|
||||
@StateObject private var monitor = ProximityMonitor()
|
||||
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||
/// network/offline strip (ContentView renders it above the tabs).
|
||||
@StateObject private var tipStore = TipStore()
|
||||
|
||||
// --- Explicit banner animation state (robust fade-out) ---
|
||||
// SwiftUI's removal transition for the banner proved unreliable here
|
||||
// (the tip faded to nothing only in the cross-nil case, and even then
|
||||
// inconsistently). Drive opacity/offset explicitly instead so a
|
||||
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||||
@State private var currentBanner: AppBanner?
|
||||
@State private var bannerOpacity: Double = 0
|
||||
@State private var bannerOffset: CGFloat = 0
|
||||
@State private var bannerClearTask: Task<Void, Never>?
|
||||
|
||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||
@@ -266,20 +307,12 @@ struct ContentView: View {
|
||||
if FuelStore.loadHasCompletedOnboarding()
|
||||
|| shouldSkipOnboarding {
|
||||
locationManager.startForegroundTracking()
|
||||
// Geofences must follow the user even in the background:
|
||||
// wire the delegate hook (fires on every fix incl. background
|
||||
// significant-change wake-ups) to re-register the region
|
||||
// window around the new position. SwiftUI onChange alone
|
||||
// never runs while suspended, so alerts would otherwise stay
|
||||
// frozen around the last foreground fix.
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
}
|
||||
// Geofences and the Live Activity must follow the user even in
|
||||
// the background: wire the delegate hook (fires on every fix
|
||||
// incl. background significant-change wake-ups) so SwiftUI's
|
||||
// foreground-only onChange isn't the only update path.
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
monitor.setEnabled(alertsEnabled)
|
||||
@@ -310,6 +343,8 @@ struct ContentView: View {
|
||||
// empty on a brand-new install.
|
||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
@@ -376,9 +411,7 @@ struct ContentView: View {
|
||||
monitor.setEnabled(newValue)
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
if newValue {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
}
|
||||
.onChange(of: alertsRadius) { _, newValue in
|
||||
FuelStore.saveAlertsRadius(newValue)
|
||||
@@ -409,6 +442,7 @@ struct ContentView: View {
|
||||
// Toggling the Live Activity on starts it with the current best
|
||||
// station; toggling off ends any running activity.
|
||||
FuelStore.saveLiveActivityEnabled(newValue)
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
updateLiveActivity()
|
||||
}
|
||||
.onChange(of: liveActivityFuel) { _, newValue in
|
||||
@@ -447,38 +481,91 @@ 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)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBannerView: some View {
|
||||
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)
|
||||
)
|
||||
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: "")
|
||||
)
|
||||
case .live:
|
||||
EmptyView()
|
||||
private var mainContent: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
rootTabView
|
||||
if let banner = currentBanner {
|
||||
// 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. Opacity/offset are driven explicitly by
|
||||
// reflectBanner so the fade-out reliably animates.
|
||||
floatingBanner(banner)
|
||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||
.opacity(bannerOpacity)
|
||||
.offset(y: bannerOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: activeBanner) {
|
||||
// Fires on launch with the current banner AND whenever it changes —
|
||||
// unlike onChange(computed) which can miss the first non-nil value
|
||||
// when dataStatus is set slightly after the view appears.
|
||||
reflectBanner(activeBanner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly animates the banner in/out — a robust replacement for the
|
||||
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||||
/// - first appearance → fade + settle down from slightly above
|
||||
/// - any content change (tip→network, new tip) → fade the old out, then
|
||||
/// fade the new in — so a tip ALWAYS visibly fades away
|
||||
/// - dismissal → fade out + slide up, then clear after the fade
|
||||
private func reflectBanner(_ newBanner: AppBanner?) {
|
||||
guard newBanner != currentBanner else { return }
|
||||
bannerClearTask?.cancel()
|
||||
if let newBanner {
|
||||
if currentBanner == nil {
|
||||
present(newBanner)
|
||||
} else {
|
||||
// Crossfade: slide+fade the current out, then present the new.
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
present(newBanner)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
currentBanner = nil
|
||||
bannerOpacity = 0
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade a banner in from slightly above and settle it into place.
|
||||
private func present(_ banner: AppBanner) {
|
||||
currentBanner = banner
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -16
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
bannerOpacity = 1
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,13 +596,51 @@ struct ContentView: View {
|
||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||
}
|
||||
|
||||
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||
/// Tapping 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 {
|
||||
Button {
|
||||
Task { await refresh(force: true) }
|
||||
} label: {
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
@@ -525,12 +650,14 @@ struct ContentView: View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Image(systemName: trailingIcon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
@@ -543,13 +670,13 @@ 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)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
/// Pushes the current best-in-radius station into the Live Activity.
|
||||
@@ -637,6 +764,7 @@ struct ContentView: View {
|
||||
/// within the compiler's type-check budget.
|
||||
private var settingsTab: some View {
|
||||
SettingsView(
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
alertsFuel: alertsFuel,
|
||||
@@ -803,14 +931,14 @@ struct StationRow: View {
|
||||
.padding(5)
|
||||
} else {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.black.opacity(0.82))
|
||||
}
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
.background(Circle().fill(.white))
|
||||
.background(Circle().fill(Color.white))
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||
|
||||
// MIDDLE — name, full address, distance
|
||||
|
||||
Reference in New Issue
Block a user