Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73a64023f | ||
|
|
dc6d1fd724 | ||
|
|
10e3b54d0c | ||
|
|
90fb3b7385 | ||
|
|
64ef260959 | ||
|
|
28bb245c93 | ||
|
|
d8412c132a | ||
|
|
1d62204a7a | ||
|
|
bdfbbc605f | ||
|
|
2c6e63f6b1 | ||
|
|
a6620d36f7 | ||
|
|
227cce4aa6 | ||
|
|
01bb3ba905 |
@@ -50,6 +50,23 @@ struct ContentView: View {
|
|||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||||||
|
/// stations around the current fix. The app owns routing — the widget and
|
||||||
|
/// Live Activity only read the cached result. Falls back to the last saved
|
||||||
|
/// location so it can run before the first fresh GPS fix arrives.
|
||||||
|
private func refreshRoadDistancesIfNeeded() {
|
||||||
|
let origin = location ?? FuelStore.loadLocation()
|
||||||
|
guard let origin else { return }
|
||||||
|
Task {
|
||||||
|
await RoadDistanceService.refreshIfNeeded(
|
||||||
|
stations: stations,
|
||||||
|
lat: origin.lat,
|
||||||
|
lng: origin.lng
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +334,10 @@ struct ContentView: View {
|
|||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
// Compute road distances early (throttled; falls back to the
|
||||||
|
// last saved location) so distance surfaces are road-matched
|
||||||
|
// as soon as stations are available.
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
// Skipped under the force-* hooks so the banner stays up.
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -362,6 +383,7 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
// No network fetch on foreground — pull-to-refresh is the override.
|
// No network fetch on foreground — pull-to-refresh is the override.
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
@@ -377,6 +399,7 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -949,7 +972,7 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let location {
|
if let location {
|
||||||
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FuelBoardApp: App {
|
struct FuelBoardApp: App {
|
||||||
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
|
||||||
|
// Live Activity with a long station name so the Lock Screen / island
|
||||||
|
// layout can be rendered in the Simulator for visual QA.
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
if let idx = args.firstIndex(of: "-qaLiveActivity"),
|
||||||
|
args.indices.contains(idx + 1),
|
||||||
|
let fuel = FuelType(rawValue: args[idx + 1]) {
|
||||||
|
startQALiveActivity(fuel: fuel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func startQALiveActivity(fuel: FuelType) {
|
||||||
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||||
|
fuel: fuel,
|
||||||
|
stationID: "qa-phoenix",
|
||||||
|
stationName: "Phoenix Filling Stations",
|
||||||
|
brand: "Phoenix",
|
||||||
|
pricePence: 1499,
|
||||||
|
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||||
|
distanceKM: 8.0,
|
||||||
|
lat: 51.5,
|
||||||
|
lng: -0.12,
|
||||||
|
updatedAt: Date()
|
||||||
|
)
|
||||||
|
let attrs = FuelBoardLiveActivityAttributes()
|
||||||
|
do {
|
||||||
|
let activity = try Activity.request(
|
||||||
|
attributes: attrs,
|
||||||
|
content: .init(state: state, staleDate: nil),
|
||||||
|
pushType: nil
|
||||||
|
)
|
||||||
|
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||||
|
} catch {
|
||||||
|
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||||
/// two cases:
|
/// two cases:
|
||||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
|||||||
brand: best.brand,
|
brand: best.brand,
|
||||||
pricePence: price,
|
pricePence: price,
|
||||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||||
lat: best.lat,
|
lat: best.lat,
|
||||||
lng: best.lng,
|
lng: best.lng,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
||||||
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
||||||
|
// show real driving distance instead of straight-line haversine.
|
||||||
|
//
|
||||||
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
||||||
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
||||||
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||||
|
//
|
||||||
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||||
|
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
||||||
|
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum RoadDistanceService {
|
||||||
|
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||||
|
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||||
|
/// still get real road distances instead of a straight-line fallback.
|
||||||
|
static let candidatesPerPass = 40
|
||||||
|
/// Only route stations within this straight-line radius (km). Covers the
|
||||||
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
||||||
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||||
|
static let maxRadiusKM: Double = 25
|
||||||
|
/// Don't route again more often than this (minutes).
|
||||||
|
static let throttleMinutes: Double = 10
|
||||||
|
/// Recompute when the user moves more than this (metres) from the last
|
||||||
|
/// source location.
|
||||||
|
static let moveThresholdMeters: Double = 400
|
||||||
|
|
||||||
|
/// Refreshes the cached road distances for the in-radius stations around
|
||||||
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||||
|
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||||
|
guard !stations.isEmpty else { return }
|
||||||
|
|
||||||
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||||
|
if let cache = FuelStore.loadRoadDistances() {
|
||||||
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||||
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||||
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||||
|
// could actually display, capped so a pass stays bounded.
|
||||||
|
let nearest = stations
|
||||||
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||||
|
.prefix(candidatesPerPass)
|
||||||
|
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||||
|
|
||||||
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
|
var entries: [String: Double] = [:]
|
||||||
|
for station in nearest {
|
||||||
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
if let meters = await roadMeters(from: origin, to: dest) {
|
||||||
|
entries[station.id] = meters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||||
|
// Wake the widgets so the new road distances surface immediately.
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
|
let request = MKDirections.Request()
|
||||||
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||||
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||||
|
request.transportType = .automobile
|
||||||
|
request.requestsAlternateRoutes = false
|
||||||
|
do {
|
||||||
|
let response = try await MKDirections(request: request).calculate()
|
||||||
|
return response.routes.first?.distance
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-line haversine distance between two coordinates, in metres.
|
||||||
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||||
|
let r = 6371000.0
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLng = (lng2 - lng1) * .pi / 180
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,17 +317,6 @@ extension FuelType {
|
|||||||
case .diesel: return "Diesel"
|
case .diesel: return "Diesel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
|
||||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
|
|
||||||
/// fuel-type tab icons and the title icon.
|
|
||||||
var tintColor: Color {
|
|
||||||
switch self {
|
|
||||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
|
||||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
|
||||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
||||||
|
|||||||
@@ -620,3 +620,54 @@ final class OfflineDataLabelTests: XCTestCase {
|
|||||||
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Road distance cache
|
||||||
|
|
||||||
|
final class RoadDistanceCacheTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
// Keychain persists across invocations, so a cache left by an earlier
|
||||||
|
// test or run would pollute these. Overwrite with an empty, far-away
|
||||||
|
// cache (source at (0,0)) so every test starts from a clean slate.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||||
|
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
|
||||||
|
// London user, station ~ London -> no cache -> straight-line haversine.
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedWhenCachedNear() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceNilWhenOriginFar() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200])
|
||||||
|
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertNil(meters)
|
||||||
|
// And display falls back to straight-line.
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
||||||
|
// A station that isn't in the cache falls back to straight-line.
|
||||||
|
let s = station("z", 51.51, -0.13)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
@@ -49,12 +49,15 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
.font(.caption2.bold().monospacedDigit())
|
.font(.caption2.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NOTE: deliberately NO `.supplementalActivityFamilies([.small])`.
|
.supplementalActivityFamilies([.small])
|
||||||
// That modifier makes iOS eligible to render this activity in the
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
// narrow `.small` form on the iPhone/iPad Lock Screen, which is what
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
// produced the squeezed, small-text card. Dropping it keeps the
|
// instead of falling back to the Dynamic Island compact closure —
|
||||||
// full-width Lock Screen card on iPhone/iPad; CarPlay still shows a
|
// which could NOT show the station distance the user wants on CarPlay.
|
||||||
// small form via the Dynamic Island compact closures below.
|
// The full-width iPhone/iPad card is protected by the `richMinWidth`
|
||||||
|
// gate on `richBody` + its flexible, truncating middle column, so
|
||||||
|
// iPhone/iPad still get the full card; only truly small space picks
|
||||||
|
// the compact strip below.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,29 +65,59 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
private struct FuelBoardLiveActivityView: View {
|
private struct FuelBoardLiveActivityView: View {
|
||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
|
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
|
||||||
|
/// small / Watch smart stack); at/above it we show the full card. The
|
||||||
|
/// decision is made from the real width the system hands the body, read
|
||||||
|
/// via a background GeometryReader — NOT ViewThatFits ideal-width
|
||||||
|
/// measurement (that's broken for truncating text: a long station name
|
||||||
|
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
|
||||||
|
/// the full card).
|
||||||
|
private let compactWidthThreshold: CGFloat = 280
|
||||||
|
|
||||||
|
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
|
||||||
|
/// background GeometryReader so it does NOT act as the layout container:
|
||||||
|
/// a GeometryReader root pins content top-left, and forcing a
|
||||||
|
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
|
||||||
|
/// centring the content below true vertical centre (bigger gap above) on
|
||||||
|
/// the Lock Screen. Measuring behind the scenes keeps the content
|
||||||
|
/// intrinsic-sized so the system vertically centres it itself.
|
||||||
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
// Always the full three-column card. The station caption is
|
Group {
|
||||||
// line-limited + scale-down + tail-truncated, so a LONG station
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// name truncates in place instead of inflating this view's ideal
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
// width and tricking ViewThatFits into falling back to the compact
|
// how long the station name is. Truly small slots (CarPlay /
|
||||||
// strip (that is exactly what made 5-mi / long-named activities
|
// Watch) offer much less → compact strip.
|
||||||
// render small while 10-15-mi / short names stayed full).
|
if slotWidth >= compactWidthThreshold {
|
||||||
//
|
richBody
|
||||||
// No ViewThatFits / compactBody: with `.supplementalActivityFamilies`
|
} else {
|
||||||
// removed, this body is only ever handed Lock-Screen width, so the
|
compactBody
|
||||||
// compact fallback was both dead weight and the cause of the bug.
|
}
|
||||||
richBody
|
}
|
||||||
|
// Fill the card width so the background measure reads the real
|
||||||
|
// slot, not the intrinsic content width.
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
// Side-channel width measurement — never the layout container.
|
||||||
|
.background(
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.onAppear { slotWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, w in slotWidth = w }
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
private var richBody: some View {
|
private var richBody: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — station brand glyph
|
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
// background circle behind it (user request).
|
||||||
.font(.system(size: 32))
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green, .white)
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
// MIDDLE — fuel + station
|
// MIDDLE — fuel + station
|
||||||
@@ -113,7 +146,31 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: `compactBody` was removed — always render `richBody` (see body).
|
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||||
|
/// fuel type + bold price on one line, station · distance below.
|
||||||
|
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
||||||
|
/// display-only, and the user's asks here are just fuel + price + distance.
|
||||||
|
private var compactBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(context.state.fuel.displayName)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 15, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-4
@@ -8,6 +8,7 @@
|
|||||||
// keychain → app-group defaults → fallback.
|
// keychain → app-group defaults → fallback.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import Security
|
import Security
|
||||||
#if canImport(AppIntents)
|
#if canImport(AppIntents)
|
||||||
import AppIntents
|
import AppIntents
|
||||||
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||||
|
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
|
||||||
|
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
|
||||||
|
/// definition.
|
||||||
|
extension FuelType {
|
||||||
|
var tintColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||||
|
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||||
|
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(AppIntents)
|
#if canImport(AppIntents)
|
||||||
extension FuelType: AppEnum {}
|
extension FuelType: AppEnum {}
|
||||||
#endif
|
#endif
|
||||||
@@ -919,15 +934,75 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||||
// (a test button in the Alerts tab re-opens it). Stored in the app group
|
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
|
||||||
// so the widget can see it too if ever needed.
|
// (with an app-group mirror) for the same reason as favourites/distance
|
||||||
|
// unit: free SideStore accounts don't provision the app-group container,
|
||||||
|
// so an app-group-only flag silently fails to save AND reloads as false,
|
||||||
|
// making onboarding re-appear on every launch. Keychain survives reinstall
|
||||||
|
// and is shared with the extension.
|
||||||
|
|
||||||
static func loadHasCompletedOnboarding() -> Bool {
|
static func loadHasCompletedOnboarding() -> Bool {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
|
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||||
|
|
||||||
|
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||||
|
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
|
||||||
|
/// SideStore accounts where the app-group container isn't provisioned) so
|
||||||
|
/// the widget extension can read it too. Widget + Live Activity prefer
|
||||||
|
/// these over straight-line haversine for the displayed distance.
|
||||||
|
static let roadDistancesKey = "fuelboard.roadDistances"
|
||||||
|
|
||||||
|
/// How far (metres) the cache's source location may be from the current
|
||||||
|
/// user position before a cached road distance is treated as stale.
|
||||||
|
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||||
|
|
||||||
|
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) {
|
||||||
|
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
|
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||||
|
if let data = try? JSONEncoder().encode(cache) {
|
||||||
|
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||||
|
guard let raw = loadString(service: roadDistancesKey),
|
||||||
|
let data = Data(base64Encoded: raw),
|
||||||
|
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached road distance (metres) to a station from the user's location, or
|
||||||
|
/// nil when not cached / the cache was built too far from where the user
|
||||||
|
/// is now.
|
||||||
|
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let cache = loadRoadDistances(),
|
||||||
|
let meters = cache.entries[stationID] else { return nil }
|
||||||
|
// The cache is only valid near the location it was built from.
|
||||||
|
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||||
|
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||||
|
let r = 6371000.0
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||||
|
return meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
|
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) {
|
||||||
|
return meters / 1000.0
|
||||||
|
}
|
||||||
|
return station.distanceKM(to: userLat, lng2: userLng)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
@@ -989,3 +1064,12 @@ struct FuelStore {
|
|||||||
loadString(service: "widget.diag.\(intentType)")
|
loadString(service: "widget.diag.\(intentType)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres.
|
||||||
|
struct RoadDistanceCache: Codable {
|
||||||
|
let sourceLat: Double
|
||||||
|
let sourceLng: Double
|
||||||
|
let updatedAt: TimeInterval
|
||||||
|
let entries: [String: Double]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user