Author SHA1 Message Date
FuelBoard Contributor 5d26509034 live activity: always render full card (drop compact fallback)
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
2026-08-19 16:39:49 +01:00
FuelBoard Contributor 221b96097d live activity: drop .small family so iPhone/iPad show full-width card
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
2026-08-19 16:09:02 +01:00
FuelBoard Contributor b012232012 live activity: robust full-vs-compact split; scale long station names
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
2026-08-19 13:58:54 +01:00
FuelBoard Contributor 727ef6e58f banner: explicit opacity fade (transitions were unreliable)
SwiftUI's removal transition never actually played despite .id keying — the
tip banner vanished instantly on dismiss. Replace it with explicit opacity/
offset driven by withAnimation in reflectBanner (gated by a .task(id:
activeBanner) so presentation is reliable and prompt): a dismissal now
fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses
the same state-driven pattern that fixed the HermesCall ticker fades.
2026-08-19 11:57:32 +01:00
FuelBoard Contributor 4c222cdf55 banner: key by value so tip fades out on dismiss
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
2026-08-19 11:10:29 +01:00
FuelBoard Contributor cfd435d12a 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).
2026-08-19 10:17:24 +01:00
FuelBoard Contributor 8936643e0e banner: float below nav bar (no glass pass-through) + slide-up dismiss
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
2026-08-19 08:43:35 +01:00
FuelBoard Contributor ba83f9e1c0 banner: unify network+tip into ONE floating overlay (never layout-shifting)
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
2026-08-19 08:17:50 +01:00
FuelBoard Contributor 57dffb6c5e tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
2026-08-19 07:48:10 +01:00
FuelBoard Contributor 2205a585f1 onboarding: animated feature icons 2026-08-19 07:09:54 +01:00
FuelBoard Contributor f9b70dbb9d anim: consistent bold crossfade in fuel-type picker 2026-08-19 07:09:54 +01:00
FuelBoard Contributor e00e3ff365 backlog: refine foresight live activity notes 2026-08-18 19:33:54 +01:00
FuelBoard Contributor 4f8bf96ca2 backlog: add foresight live activity investigation 2026-08-18 18:15:33 +01:00
FuelBoard Contributor 8945190da5 fix: keep live activity in sync with motion 2026-08-18 17:59:03 +01:00
FuelBoard Contributor 2e23dcb9ae ui: keep station brand badges white 2026-08-18 12:56:49 +01:00
FuelBoard Contributor db916cafcb ui: fix fallback station badge visibility 2026-08-18 12:47:49 +01:00
FuelBoard Contributor 1dd7836b8b assets: expand station brand icon coverage 2026-08-18 12:35:03 +01:00
FuelBoard Contributor e512cec958 data: refresh bundled fuel dump 2026-08-18 11:05:09 +01:00
FuelBoard Contributor dcd5517825 Live Activity: add distance to compact CarPlay layout 2026-08-18 10:00:19 +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 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 rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half. 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 - [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved). trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's - [ ] **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 CoreLocation
import WidgetKit 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 { struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -32,6 +41,24 @@ struct ContentView: View {
private var effectiveAlertsRadiusKM: Double { private var effectiveAlertsRadiusKM: Double {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius) 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 isLoading = false
@State private var statusMessage = "" @State private var statusMessage = ""
/// What data is on screen, driving which (if any) status banner shows /// 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 selectedTab = 0
@State private var locationManager = LocationManager() @State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor() @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 /// 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 /// bounds it ("best price within X miles"); in Closest mode the radius is
@@ -266,20 +307,12 @@ struct ContentView: View {
if FuelStore.loadHasCompletedOnboarding() if FuelStore.loadHasCompletedOnboarding()
|| shouldSkipOnboarding { || shouldSkipOnboarding {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
// Geofences must follow the user even in the background: // Geofences and the Live Activity must follow the user even in
// wire the delegate hook (fires on every fix incl. background // the background: wire the delegate hook (fires on every fix
// significant-change wake-ups) to re-register the region // incl. background significant-change wake-ups) so SwiftUI's
// window around the new position. SwiftUI onChange alone // foreground-only onChange isn't the only update path.
// never runs while suspended, so alerts would otherwise stay installLocationUpdateHook()
// frozen around the last foreground fix. ensureBackgroundMotionTrackingIfNeeded()
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()
}
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
@@ -310,6 +343,8 @@ struct ContentView: View {
// empty on a brand-new install. // empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() { if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
@@ -373,9 +408,7 @@ struct ContentView: View {
monitor.setEnabled(newValue) monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue { ensureBackgroundMotionTrackingIfNeeded()
locationManager.startBackgroundTracking()
}
} }
.onChange(of: alertsRadius) { _, newValue in .onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue) FuelStore.saveAlertsRadius(newValue)
@@ -406,6 +439,7 @@ struct ContentView: View {
// Toggling the Live Activity on starts it with the current best // Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity. // station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue) FuelStore.saveLiveActivityEnabled(newValue)
ensureBackgroundMotionTrackingIfNeeded()
updateLiveActivity() updateLiveActivity()
} }
.onChange(of: liveActivityFuel) { _, newValue in .onChange(of: liveActivityFuel) { _, newValue in
@@ -444,38 +478,91 @@ struct ContentView: View {
) )
} }
private var mainContent: some View { /// The one banner to show right now. Network/offline and tip outcomes all
VStack(spacing: 0) { /// funnel here and render through the SAME floating chrome. A transient
statusBannerView /// tip (auto-dismissed by TipStore) takes precedence over the persistent
rootTabView /// 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 mainContent: some View {
private var statusBannerView: some View { GeometryReader { geo in
switch dataStatus { ZStack(alignment: .top) {
case .offlineDump(let date): rootTabView
let title = offlineTitle(date: date) if let banner = currentBanner {
statusBanner( // Floating near the top of the screen, over the nav area
icon: "wifi.slash", // overlays content (never pushes it) and sits above the
tint: .orange, // main content so it doesn't cover or block the list/pill
title: title, // beneath it. Opacity/offset are driven explicitly by
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""), // reflectBanner so the fade-out reliably animates.
accessibilityLabel: date.isEmpty floatingBanner(banner)
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "") .padding(.top, geo.safeAreaInsets.top + 10)
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date) .opacity(bannerOpacity)
) .offset(y: bannerOffset)
case .connectionProblem: }
statusBanner( }
icon: "wifi.exclamationmark", }
tint: .red, .task(id: activeBanner) {
title: NSLocalizedString("Check your internet connection", comment: ""), // Fires on launch with the current banner AND whenever it changes
subtitle: NSLocalizedString("Tap to try again", comment: ""), // unlike onChange(computed) which can miss the first non-nil value
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "") // when dataStatus is set slightly after the view appears.
) reflectBanner(activeBanner)
case .live: }
EmptyView() }
/// 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
} }
} }
@@ -506,13 +593,51 @@ struct ContentView: View {
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date) : String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
} }
/// Shared status-strip chrome: a tappable card pinned above the tabs. /// ONE floating banner style for all notifications. Network/offline and
/// Tapping retries the live fetch from ANY screen no pull gesture /// tip outcomes share the same chrome; only the tap action differs
/// needed, so the offline banner isn't trapped on the Stations tab. /// network retry the fetch, tip dismiss (TipStore auto-dismisses too).
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View { @ViewBuilder
Button { 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) } 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) { HStack(spacing: 10) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
@@ -522,12 +647,14 @@ struct ContentView: View {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
if let subtitle {
Text(subtitle) Text(subtitle)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
}
Spacer() Spacer()
Image(systemName: "arrow.clockwise") Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint) .foregroundStyle(tint)
} }
@@ -540,13 +667,13 @@ struct ContentView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1) .stroke(tint.opacity(0.35), lineWidth: 1)
) )
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
) )
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 6)
} }
.contentShape(Rectangle())
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity))
} }
/// Pushes the current best-in-radius station into the Live Activity. /// Pushes the current best-in-radius station into the Live Activity.
@@ -634,6 +761,7 @@ struct ContentView: View {
/// within the compiler's type-check budget. /// within the compiler's type-check budget.
private var settingsTab: some View { private var settingsTab: some View {
SettingsView( SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle, priceDisplayStyle: $priceDisplayStyle,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
@@ -789,14 +917,14 @@ struct StationRow: View {
.padding(5) .padding(5)
} else { } else {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.system(size: 22)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(Color.black.opacity(0.82))
} }
} }
.frame(width: 46, height: 46) .frame(width: 46, height: 46)
.background(Circle().fill(.white)) .background(Circle().fill(Color.white))
.clipShape(Circle()) .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) .shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE name, full address, distance // MIDDLE name, full address, distance
+111 -13
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View { private var welcomePage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner // The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the // treatment (~22% of the size), so onboarding matches the
// Home Screen. // Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View { private var locationPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "location.fill") Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View { private var notificationsPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "bell.badge.fill") Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange) .foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View { private var dataPage: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
ZStack { AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
Image(systemName: "arrow.down.circle.fill") Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold)) .font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal) .foregroundStyle(.teal)
@@ -248,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.
@@ -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 /// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live. /// their outcomes so the pages can reflect them live.
@MainActor @MainActor
+66 -19
View File
@@ -13,6 +13,9 @@ import WidgetKit
/// freshness) is debug-only too moved here so release builds don't expose /// freshness) is debug-only too moved here so release builds don't expose
/// relay plumbing. /// relay plumbing.
struct SettingsView: View { 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 distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle @Binding var priceDisplayStyle: PriceDisplayStyle
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
@@ -53,9 +56,6 @@ struct SettingsView: View {
/// Clears the armed fence (monitor.clearDebugFence). /// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {} var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String? @State private var testAlertMessage: String?
/// Hidden developer flag the Debug section only appears when on. Toggled /// Hidden developer flag the Debug section only appears when on. Toggled
/// by tapping the About Version row five times (NOT a user-facing switch). /// by tapping the About Version row five times (NOT a user-facing switch).
@@ -359,13 +359,6 @@ struct SettingsView: View {
Text("About") Text("About")
} }
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onAppear { .onAppear {
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A 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 products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false @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() /// Listens for transactions that complete OUTSIDE the direct purchase()
/// call Ask to Buy approvals, payments finished on another device /// call Ask to Buy approvals, payments finished on another device
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
deinit { deinit {
updatesTask?.cancel() updatesTask?.cancel()
dismissTask?.cancel()
} }
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async { 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). // Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return } guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish() 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 { 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 // Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button. // outcome rather than a dead button.
guard let product = products[tier.id] else { 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 return
} }
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
// Consume the consumable otherwise StoreKit re-delivers // Consume the consumable otherwise StoreKit re-delivers
// it through Transaction.updates on every launch. // it through Transaction.updates on every launch.
await transaction.finish() 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: 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: case .userCancelled:
message = nil // silent the user just closed the sheet dismissOutcome() // silent the user just closed the sheet
case .pending: 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: @unknown default:
message = nil dismissOutcome()
} }
} catch { } 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 fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in } var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: 3) {
ForEach(fuels) { fuel in ForEach(fuels) { fuel in
let isSelected = fuel == selection let isSelected = fuel == selection
Button { Button {
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel selection = fuel
}
onSelect(fuel) onSelect(fuel)
} label: { } label: {
HStack(spacing: 5) { HStack(spacing: 5) {
Image(systemName: "fuelpump.fill") Image(systemName: "fuelpump.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(fuel.tintColor) .foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName) Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular)) .font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
} }
.foregroundStyle(isSelected ? Color.primary : Color.secondary) .foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -364,14 +370,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected { if isSelected {
Capsule() Capsule()
.fill(Color(UIColor.systemBackground)) .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) .buttonStyle(.plain)
} }
} }
.padding(3) .padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground))) .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: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco") XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen") 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() { func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName) XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
XCTAssertNil(station(brand: "VALERO").brandImageName) XCTAssertNil(station(brand: "SOLO").brandImageName)
XCTAssertNil(station(brand: "PACE").brandImageName)
XCTAssertNil(station(brand: "").brandImageName) XCTAssertNil(station(brand: "").brandImageName)
} }
} }
@@ -4,27 +4,25 @@
// Shows the cheapest station for the pinned fuel within the app's chosen // Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station. // radius. Tapping anywhere opens Apple Maps directions to that station.
// //
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the // ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// Lock Screen, banner, and the CarPlay small slot there is no per-platform // ViewThatFits pick by available width, but the narrow `.small` family is NOT
// closure. So this view provides two layouts and lets ViewThatFits pick by // declared (see note on the config), so iPhone/iPad always render the full
// available width: // three-column card (`richBody`) with proper text sizes. `compactBody` is kept
// richBody the full three-column design (glyph · fuel+station · price), // as a safety fallback should any surface ever hand this view a narrow width.
// wins wherever there's Lock Screen width (it carries an //
// explicit minWidth so it can never be squeezed into the car). // IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent
// compactBody a minimal price-strip (glyph+fuel+price, station caption // it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
// below) that wins in the CarPlay/Apple Watch Smart Stack // CarPlay's small form comes from the Dynamic Island compact closures.
// small slot.
import ActivityKit import ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// The Live Activity itself registered in the widget bundle alongside the /// The Live Activity itself registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the /// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+). /// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's /// (incl. the island's compact form used in the car, display-only FuelBoard
/// small Live Activity slot display-only there (FuelBoard is not a /// is not a CarPlay-enabled app, so car-side taps can't launch anything).
/// CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget { struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
@@ -51,7 +49,12 @@ struct FuelBoardLiveActivity: Widget {
.font(.caption2.bold().monospacedDigit()) .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 { var body: some View {
Link(destination: context.state.mapsURL) { Link(destination: context.state.mapsURL) {
ViewThatFits(in: .horizontal) { // Always the full three-column card. The station caption is
// rich first wins on full-width Lock Screen / banner // 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 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) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -106,30 +113,7 @@ private struct FuelBoardLiveActivityView: View {
.padding() .padding()
} }
/// Minimal strip for the small CarPlay / Watch Smart Stack slot: // NOTE: `compactBody` was removed always render `richBody` (see body).
/// 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)
}
} }
/// Dynamic Island expanded regions + compact trailing price only. /// Dynamic Island expanded regions + compact trailing price only.
+11 -3
View File
@@ -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
+16
View File
@@ -242,21 +242,37 @@ struct FuelStation: Identifiable, Codable, Equatable {
var brandImageName: String? { var brandImageName: String? {
let raw = brand.uppercased() let raw = brand.uppercased()
let known: [(String, String)] = [ let known: [(String, String)] = [
("CIRCLE K", "brand_circle_k"),
("MAXOL", "brand_maxol"),
("SPAR", "brand_spar"),
("SHELL", "brand_shell"), ("SHELL", "brand_shell"),
("SAINSBURY", "brand_sainsburys"), ("SAINSBURY", "brand_sainsburys"),
("MORRISONS", "brand_morrisons"), ("MORRISONS", "brand_morrisons"),
("APPLEGREEN", "brand_applegreen"), ("APPLEGREEN", "brand_applegreen"),
("TESCO", "brand_tesco"), ("TESCO", "brand_tesco"),
("TEXACO", "brand_texaco"), ("TEXACO", "brand_texaco"),
("ESSAR", "brand_essar"),
("ESSO", "brand_esso"), ("ESSO", "brand_esso"),
("ASDA", "brand_asda"), ("ASDA", "brand_asda"),
("GULF", "brand_gulf"), ("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"), ("BP", "brand_bp"),
("JET", "brand_jet"), ("JET", "brand_jet"),
] ]
for (needle, asset) in known where raw.contains(needle) { for (needle, asset) in known where raw.contains(needle) {
return asset 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 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 { 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
} }