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:
FuelBoard Contributor
2026-08-19 18:11:13 +01:00
41 changed files with 675 additions and 156 deletions
+19
View File
@@ -144,6 +144,25 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
separate phase. Backlog ancestry: this was the original "relay could keep a
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half.
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
driving-time surface that predicts the likely next station ahead of the user
from cached data only (no extra source-data refresh on motion). Best v1 shape:
Live Activity option, not widget-first, because widget space/refresh budgets
are tight and the lock-screen/CarPlay compact slot better matches the use case.
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
vs `Foresight / next ahead`) rather than trying to run two simultaneous
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
current implementation is single-activity and the compact surfaces are too
constrained for two competing driving modes anyway. Viable MVP: infer heading
from recent motion/course, keep only stations in a forward cone, score
candidates by forward distance + lateral offset, and show the next-ahead
station with all three fuel prices or a compact rotating/focused fuel
treatment if space allows. Key caveat: heading is not full route awareness,
so avoid overpromising "the next station you will reach" on motorways/opposite
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
and should be tested specifically against compact Live Activity/CarPlay layouts
where space is extremely limited.
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_circle_k_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_circle_k_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_essar_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_essar_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_gleaner_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_gleaner_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_highland_fuels_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_highland_fuels_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_maxol_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_maxol_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_murco_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_murco_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_spar_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_spar_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_the_co_operative_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_the_co_operative_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_valero_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_valero_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "brand_welcome_break_2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "brand_welcome_break_3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+187 -59
View File
@@ -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 (tipnetwork, 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 {
/// 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) }
} label: {
}
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)
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
+111 -13
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal)
@@ -248,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.
@@ -326,6 +331,99 @@ struct OnboardingView: View {
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
case alarmBell
case downloadArrow
}
let tint: Color
var motion: Motion = .gentle
@ViewBuilder var content: Content
@State private var settled = false
@State private var iconRotation: Double = 0
@State private var iconOffsetY: CGFloat = 0
@State private var iconScale: CGFloat = 0.92
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
content
.scaleEffect(iconScale)
.rotationEffect(.degrees(iconRotation))
.offset(y: iconOffsetY)
}
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
.onAppear {
playAnimation()
}
}
private func playAnimation() {
settled = false
iconRotation = 0
iconOffsetY = 4
iconScale = 0.92
switch motion {
case .gentle:
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
settled = true
iconOffsetY = 0
iconScale = 1.0
}
case .locationArrow:
iconRotation = -120
withAnimation(.linear(duration: 0.45)) {
settled = true
iconRotation = 24
iconOffsetY = 0
iconScale = 1.02
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
iconRotation = 0
iconScale = 1.0
}
case .alarmBell:
settled = true
iconOffsetY = 0
iconScale = 1.0
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
case .downloadArrow:
iconOffsetY = -22
withAnimation(.easeIn(duration: 0.22)) {
settled = true
iconScale = 1.0
iconOffsetY = 8
}
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
iconOffsetY = 0
}
}
}
}
/// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live.
@MainActor
+66 -19
View File
@@ -13,6 +13,9 @@ import WidgetKit
/// freshness) is debug-only too moved here so release builds don't expose
/// relay plumbing.
struct SettingsView: View {
/// Shared tip-purchase store owned by ContentView so its outcome can
/// ride the app-wide status banner (above the tabs), not a local card.
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
/// The fuel + radius currently configured for alerts (mirrors the Alerts
@@ -53,9 +56,6 @@ struct SettingsView: View {
/// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String?
/// Hidden developer flag the Debug section only appears when on. Toggled
/// by tapping the About Version row five times (NOT a user-facing switch).
@@ -359,13 +359,6 @@ struct SettingsView: View {
Text("About")
}
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.onAppear {
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
]
/// A banner outcome: the exact copy to show plus its icon + tint.
struct TipOutcome: Hashable {
let message: String
let icon: String
let tint: Color
}
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false
@Published private(set) var message: String?
@Published private(set) var outcome: TipOutcome?
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>?
/// Listens for transactions that complete OUTSIDE the direct purchase()
/// call Ask to Buy approvals, payments finished on another device
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
deinit {
updatesTask?.cancel()
dismissTask?.cancel()
}
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
@@ -638,7 +642,30 @@ final class TipStore: ObservableObject {
// Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
}
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
/// Re-triggering cancels the previous timer so rapid taps don't collide.
private func showOutcome(_ message: String, icon: String, tint: Color) {
dismissTask?.cancel()
outcome = TipOutcome(message: message, icon: icon, tint: tint)
dismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_500_000_000)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
}
}
/// Tap-to-dismiss clears the banner and cancels the pending auto-dismiss.
func dismissOutcome() {
dismissTask?.cancel()
dismissTask = nil
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
}
func displayPrice(for tier: TipTier) -> String {
@@ -667,7 +694,11 @@ final class TipStore: ObservableObject {
// Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button.
guard let product = products[tier.id] else {
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
showOutcome(
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
icon: "info.circle.fill",
tint: Color(.secondaryLabel)
)
return
}
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
// Consume the consumable otherwise StoreKit re-delivers
// it through Transaction.updates on every launch.
await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
case .unverified:
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
showOutcome(
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
icon: "exclamationmark.triangle.fill",
tint: .orange
)
}
case .userCancelled:
message = nil // silent the user just closed the sheet
dismissOutcome() // silent the user just closed the sheet
case .pending:
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
showOutcome(
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
icon: "hourglass",
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
)
@unknown default:
message = nil
dismissOutcome()
}
} catch {
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
showOutcome(
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
icon: "xmark.circle.fill",
tint: .red
)
}
}
}
+22 -1
View File
@@ -342,20 +342,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View {
HStack(spacing: 3) {
ForEach(fuels) { fuel in
let isSelected = fuel == selection
Button {
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel)
} label: {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
}
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
@@ -364,14 +370,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected {
Capsule()
.fill(Color(UIColor.systemBackground))
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
.overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
}
}
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
}
}
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}
@@ -233,11 +233,25 @@ final class BrandTests: XCTestCase {
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
XCTAssertEqual(station(brand: "VALERO").brandImageName, "brand_valero")
XCTAssertEqual(station(brand: "WELCOME BREAK").brandImageName, "brand_welcome_break")
XCTAssertEqual(station(brand: "THE CO-OPERATIVE").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "CO-OP").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "MURCO").brandImageName, "brand_murco")
XCTAssertEqual(station(brand: "GLEANER").brandImageName, "brand_gleaner")
XCTAssertEqual(station(brand: "HIGHLAND FUELS").brandImageName, "brand_highland_fuels")
XCTAssertEqual(station(brand: "CIRCLE K").brandImageName, "brand_circle_k")
XCTAssertEqual(station(brand: "MAXOL").brandImageName, "brand_maxol")
XCTAssertEqual(station(brand: "SPAR").brandImageName, "brand_spar")
XCTAssertEqual(station(brand: "ESSAR").brandImageName, "brand_essar")
XCTAssertEqual(station(brand: "EG ON THE MOVE").brandImageName, "brand_esso")
XCTAssertEqual(station(brand: "GO FORECOURT").brandImageName, "brand_gulf")
}
func testUnknownBrand() {
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
XCTAssertNil(station(brand: "VALERO").brandImageName)
func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
XCTAssertNil(station(brand: "SOLO").brandImageName)
XCTAssertNil(station(brand: "PACE").brandImageName)
XCTAssertNil(station(brand: "").brandImageName)
}
}
@@ -4,27 +4,25 @@
// Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station.
//
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
// Lock Screen, banner, and the CarPlay small slot there is no per-platform
// closure. So this view provides two layouts and lets ViewThatFits pick by
// available width:
// richBody the full three-column design (glyph · fuel+station · price),
// wins wherever there's Lock Screen width (it carries an
// explicit minWidth so it can never be squeezed into the car).
// compactBody a minimal price-strip (glyph+fuel+price, station caption
// below) that wins in the CarPlay/Apple Watch Smart Stack
// small slot.
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
// declared (see note on the config), so iPhone/iPad always render the full
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
// as a safety fallback should any surface ever hand this view a narrow width.
//
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
// CarPlay's small form comes from the Dynamic Island compact closures.
import ActivityKit
import SwiftUI
import WidgetKit
/// The Live Activity itself registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
/// small Live Activity slot display-only there (FuelBoard is not a
/// CarPlay-enabled app, so car-side taps can't launch anything).
/// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// (incl. the island's compact form used in the car, display-only FuelBoard
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
@@ -51,7 +49,12 @@ struct FuelBoardLiveActivity: Widget {
.font(.caption2.bold().monospacedDigit())
}
}
.supplementalActivityFamilies([.small])
// NOTE: deliberately NO `.supplementalActivityFamilies([.small])`.
// That modifier makes iOS eligible to render this activity in the
// narrow `.small` form on the iPhone/iPad Lock Screen, which is what
// produced the squeezed, small-text card. Dropping it keeps the
// full-width Lock Screen card on iPhone/iPad; CarPlay still shows a
// small form via the Dynamic Island compact closures below.
}
}
@@ -61,15 +64,17 @@ private struct FuelBoardLiveActivityView: View {
var body: some View {
Link(destination: context.state.mapsURL) {
ViewThatFits(in: .horizontal) {
// rich first wins on full-width Lock Screen / banner
// Always the full three-column card. The station caption is
// line-limited + scale-down + tail-truncated, so a LONG station
// name truncates in place instead of inflating this view's ideal
// width and tricking ViewThatFits into falling back to the compact
// strip (that is exactly what made 5-mi / long-named activities
// render small while 10-15-mi / short names stayed full).
//
// No ViewThatFits / compactBody: with `.supplementalActivityFamilies`
// removed, this body is only ever handed Lock-Screen width, so the
// compact fallback was both dead weight and the cause of the bug.
richBody
// CarPlay / Watch small slot is far narrower than this,
// so ViewThatFits reliably falls through to compactBody.
.frame(minWidth: 280)
// compact fallback the car's small supplemental slot
compactBody
}
}
}
@@ -90,6 +95,8 @@ private struct FuelBoardLiveActivityView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -106,30 +113,7 @@ private struct FuelBoardLiveActivityView: View {
.padding()
}
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
/// glyph + fuel left, bold price right, truncated station below.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(context.state.fuel.displayName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 15, weight: .bold)
.lineLimit(1)
}
Text(context.state.stationName)
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(8)
}
// NOTE: `compactBody` was removed always render `richBody` (see body).
}
/// Dynamic Island expanded regions + compact trailing price only.
+11 -3
View File
@@ -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
+16
View File
@@ -242,21 +242,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
var brandImageName: String? {
let raw = brand.uppercased()
let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"),
("ASDA", "brand_asda"),
("GULF", "brand_gulf"),
("VALERO", "brand_valero"),
("WELCOME BREAK", "brand_welcome_break"),
("THE CO-OPERATIVE", "brand_the_co_operative"),
("CO-OPERATIVE", "brand_the_co_operative"),
("CO OPERATIVE", "brand_the_co_operative"),
("CO-OP", "brand_the_co_operative"),
("CO OP", "brand_the_co_operative"),
("MURCO", "brand_murco"),
("GLEANER", "brand_gleaner"),
("HIGHLAND FUELS", "brand_highland_fuels"),
("BP", "brand_bp"),
("JET", "brand_jet"),
]
for (needle, asset) in known where raw.contains(needle) {
return asset
}
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
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 {
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
}