Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9102217b9 | ||
|
|
457816ec61 | ||
|
|
50036c8a67 | ||
|
|
a71b7ae20e | ||
|
|
98cf3a5736 | ||
|
|
ed41df46de | ||
|
|
9342ccc7c9 | ||
|
|
508ccc3802 | ||
|
|
69c3c17606 | ||
|
|
0b27d3d4f6 | ||
|
|
01517ba687 |
@@ -1,6 +1,7 @@
|
||||
build/
|
||||
build-release/
|
||||
build-sim/
|
||||
build-watch/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
+13
-6
@@ -113,12 +113,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
action in the Alerts tab that forces a fetch and immediately re-evaluates the
|
||||
cheapest-favourite alert. Copy should set expectation honestly: standard
|
||||
mode checks when FuelBoard refreshes prices (up to twice daily).
|
||||
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
|
||||
relaxes the current "twice daily max" data policy, expose this as a clearly
|
||||
separate mode rather than a freeform frequency slider. Example framing:
|
||||
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
|
||||
with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
|
||||
promise exact timing under iOS background scheduling.
|
||||
- [ ] **Data checking mode: Standard vs Smart** — keep the current cache-first
|
||||
/ 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
|
||||
`Smart` should try lightweight background checks of the mirror pointer
|
||||
(`latest.json` / `data_updated`) more often, and only download the full price
|
||||
dataset when a newer snapshot is available. Design goal: better freshness
|
||||
without pretending to do real-time refreshes or re-downloading the full dump
|
||||
unnecessarily. User-facing copy must be explicit and honest:
|
||||
- `Standard` — Saves battery. Refreshes price data up to twice daily.
|
||||
- `Smart` — Checks for newer data more often in the background and refreshes
|
||||
full prices only when an update is available.
|
||||
Add a footnote/subcopy that background checks are best-effort and happen only
|
||||
when iOS allows, so timing is not exact. Prefer this 2-mode setting over a
|
||||
freeform frequency slider.
|
||||
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
||||
(currently `refreshable` fires but no visible progress in the row list).
|
||||
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||
|
||||
@@ -41,8 +41,13 @@
|
||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.apt.fuelboard.smart-refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -314,6 +315,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -336,6 +338,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -360,6 +363,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
||||
@@ -16,6 +16,7 @@ struct ContentView: View {
|
||||
|
||||
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||
@@ -321,7 +322,7 @@ struct ContentView: View {
|
||||
#else
|
||||
let shouldSkipOnboarding = false
|
||||
#endif
|
||||
if FuelStore.loadHasCompletedOnboarding()
|
||||
if !FuelStore.shouldShowOnboarding()
|
||||
|| shouldSkipOnboarding {
|
||||
locationManager.startForegroundTracking()
|
||||
// Geofences and the Live Activity must follow the user even in
|
||||
@@ -365,6 +366,7 @@ struct ContentView: View {
|
||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
@@ -380,6 +382,7 @@ struct ContentView: View {
|
||||
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
|
||||
locationManager.startForegroundTracking()
|
||||
}
|
||||
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
@@ -787,6 +790,7 @@ struct ContentView: View {
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
testAlertResult: monitor.lastTestResult,
|
||||
@@ -849,6 +853,7 @@ struct ContentView: View {
|
||||
stations = fetched
|
||||
FuelStore.saveStations(fetched)
|
||||
FuelStore.saveLastRefresh()
|
||||
FuelStore.saveLastSmartProbe()
|
||||
// Persist envelope metadata (source, station count, GOV.UK
|
||||
// dataset update time) for the Settings → About section — the
|
||||
// live chain records whichever leg served the fetch.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ActivityKit
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -22,6 +23,9 @@ struct FuelBoardApp: App {
|
||||
ContentView()
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
}
|
||||
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
|
||||
await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -73,7 +73,9 @@ enum LiveActivityManager {
|
||||
currentStartDate = nil
|
||||
start(attributes: attributes, state: state)
|
||||
} else {
|
||||
Task { await current.update(using: state) }
|
||||
Task {
|
||||
await current.update(.init(state: state, staleDate: nil))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
start(attributes: attributes, state: state)
|
||||
|
||||
@@ -51,11 +51,16 @@ enum RoadDistanceService {
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||
|
||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
var entries: [String: Double] = [:]
|
||||
var entries: [String: CachedRoadDistance] = [:]
|
||||
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
|
||||
// Store the exact pin that was routed so the display layer can
|
||||
// refuse to serve this value if the station later appears with
|
||||
// a different coordinate (corrected pin / other data source).
|
||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||
lat: station.lat,
|
||||
lng: station.lng)
|
||||
}
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
@@ -68,8 +73,12 @@ enum RoadDistanceService {
|
||||
/// 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.source = MKMapItem(location: CLLocation(latitude: from.latitude,
|
||||
longitude: from.longitude),
|
||||
address: nil)
|
||||
request.destination = MKMapItem(location: CLLocation(latitude: to.latitude,
|
||||
longitude: to.longitude),
|
||||
address: nil)
|
||||
request.transportType = .automobile
|
||||
request.requestsAlternateRoutes = false
|
||||
do {
|
||||
|
||||
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
||||
@ObservedObject var tipStore: TipStore
|
||||
@Binding var distanceUnit: DistanceUnit
|
||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||
@Binding var dataRefreshMode: DataRefreshMode
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
/// tab) so the test notification matches what real alerts will say.
|
||||
var alertsFuel: FuelType = .e10
|
||||
@@ -120,6 +121,29 @@ struct SettingsView: View {
|
||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Data checking", selection: $dataRefreshMode) {
|
||||
ForEach(DataRefreshMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: dataRefreshMode) { _, newValue in
|
||||
FuelStore.saveDataRefreshMode(newValue)
|
||||
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(dataRefreshMode.summary)
|
||||
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.footnote)
|
||||
} header: {
|
||||
Text("Data checking")
|
||||
} footer: {
|
||||
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onShowOnboarding()
|
||||
|
||||
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
struct DirectionsToCheapestFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
||||
static var description = IntentDescription(
|
||||
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||
"Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||
)
|
||||
|
||||
@Parameter(title: "Fuel")
|
||||
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
|
||||
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
||||
static var description = IntentDescription(
|
||||
"Opens Apple Maps directions to your top favourite station for a fuel."
|
||||
"Opens directions to your top favourite station for a fuel."
|
||||
)
|
||||
|
||||
@Parameter(title: "Fuel")
|
||||
@@ -404,7 +404,7 @@ struct FuelMessage: View {
|
||||
/// Shortcut slot and adds no phrase.
|
||||
struct OpenDirectionsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions"
|
||||
static var description = IntentDescription("Opens Apple Maps directions to the station.")
|
||||
static var description = IntentDescription("Opens directions to the station.")
|
||||
static var isDiscoverable: Bool = false
|
||||
|
||||
@Parameter var stationName: String
|
||||
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
|
||||
|
||||
struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
// Generic parameterized shortcut — matches whatever fuel word Siri
|
||||
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
|
||||
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
|
||||
// entries below carry the fuel word as LITERAL phrase text.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
||||
],
|
||||
shortTitle: "Cheapest Fuel",
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
|
||||
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
|
||||
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
|
||||
// shortcuts for the same intent (parameter resolution was already flaky
|
||||
// on-device), which degraded Siri's NLU ranking and caused the
|
||||
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
|
||||
// routes on literal fuel words only, and the freed slot keeps us under
|
||||
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
|
||||
// metadata-processor requirement) and mirrors how people actually ask.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(fuel: .e10),
|
||||
phrases: [
|
||||
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
"Ask \(.applicationName) for the cheapest petrol near me",
|
||||
"Find the cheapest petrol near me \(.applicationName)",
|
||||
"Find the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest petrol near me \(.applicationName)",
|
||||
"Cheapest unleaded near me \(.applicationName)",
|
||||
"Where's the cheapest petrol \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Cheapest Unleaded",
|
||||
systemImageName: "fuelpump"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
enum SmartDataRefreshScheduler {
|
||||
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
|
||||
|
||||
static func register() {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
|
||||
guard let task = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
handle(task)
|
||||
}
|
||||
}
|
||||
|
||||
static func scheduleNextIfNeeded() {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
|
||||
guard FuelStore.loadDataRefreshMode() == .smart else { return }
|
||||
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("SMART-REFRESH schedule failed: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private static func handle(_ task: BGAppRefreshTask) {
|
||||
scheduleNextIfNeeded()
|
||||
let refreshTask = Task {
|
||||
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||
task.setTaskCompleted(success: success)
|
||||
}
|
||||
task.expirationHandler = {
|
||||
refreshTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SmartDataRefreshCoordinator {
|
||||
@discardableResult
|
||||
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
|
||||
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
|
||||
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
|
||||
return true
|
||||
}
|
||||
FuelStore.saveLastSmartProbe(now)
|
||||
guard !Task.isCancelled else { return false }
|
||||
|
||||
guard let latest = await FuelHistoryStore.fetchLatest() else {
|
||||
return false
|
||||
}
|
||||
|
||||
let latestDay = latest.availableTo ?? latest.date
|
||||
let latestUpdated = latest.dataUpdated
|
||||
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
|
||||
let savedUpdated = FuelStore.loadDataUpdated()
|
||||
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|
||||
|| !FuelStore.isCacheFresh(now: now)
|
||||
|| (latestDay != nil && latestDay != cachedDay)
|
||||
|| (latestUpdated != nil && latestUpdated != savedUpdated)
|
||||
|
||||
guard shouldFetchFullDump, !Task.isCancelled else { return true }
|
||||
|
||||
do {
|
||||
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||||
near: nil,
|
||||
lng: nil,
|
||||
fuel: FuelStore.loadSelectedFuel(),
|
||||
radiusKM: nil
|
||||
)
|
||||
guard !Task.isCancelled else { return false }
|
||||
FuelStore.saveStations(fetched)
|
||||
FuelStore.saveLastRefresh(now)
|
||||
if let meta = LiveChainProvider.latestMeta {
|
||||
FuelStore.saveRelayMeta(meta)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ struct TrendsView: View {
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var fuel: FuelType = .e10
|
||||
@State private var rangeDays: Int = 30
|
||||
@@ -34,6 +35,13 @@ struct TrendsView: View {
|
||||
@State private var loadFailed = false
|
||||
@State private var firstSnapshot: String?
|
||||
|
||||
/// Per-series draw-in waterline: maps stationID → how many leading points
|
||||
/// are revealed, so each line traces left→right on first appearance.
|
||||
/// Series reveal in a short staggered cascade so the draw is clearly
|
||||
/// visible even with only 2 points per line. Stays full after the first
|
||||
/// reveal so range/mode switches morph instead of re-tracing.
|
||||
@State private var revealed: [String: Int] = [:]
|
||||
|
||||
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||
/// opens where the user was — same pattern as FavouritesView.
|
||||
init(favourites: [FavouriteEntry],
|
||||
@@ -157,7 +165,13 @@ struct TrendsView: View {
|
||||
// pointer probe already done above.
|
||||
loadFailed = firstSnapshot == nil
|
||||
}
|
||||
// Always redraw the lines on a selection change. `.task(id:)` fires on
|
||||
// the initial appear and on every fuel/range switch (there's no
|
||||
// periodic refetch in this sheet), so a staggered per-series draw-in
|
||||
// replays exactly when the user picks 7/30/90 (or switches fuel) while
|
||||
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
|
||||
series = fetched
|
||||
revealSeries(fetched)
|
||||
// A failure with no data IS a connection problem — raise the global
|
||||
// banner so the user isn't stuck with a silent retry state. Success
|
||||
// clears it (only if the banner is the connection banner).
|
||||
@@ -168,6 +182,32 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the staggered per-series draw-in for the given histories:
|
||||
/// resets the waterline, then cascades each station's line left→right,
|
||||
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
|
||||
/// straight to the full state.
|
||||
private func revealSeries(_ histories: [StationHistory]) {
|
||||
let hasPoints = histories.contains { !$0.points.isEmpty }
|
||||
guard hasPoints else { return }
|
||||
if reduceMotion {
|
||||
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
|
||||
return
|
||||
}
|
||||
revealed = [:]
|
||||
let cascadeNS = UInt64(0.18 * 1_000_000_000)
|
||||
for (i, h) in histories.enumerated() {
|
||||
let sid = h.stationID
|
||||
let total = h.points.count
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
revealed[sid] = total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yLabel(_ pence: Double) -> String {
|
||||
switch mode {
|
||||
case .price:
|
||||
@@ -330,6 +370,9 @@ struct TrendsView: View {
|
||||
.font(.system(size: 30, weight: .bold, design: .default))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
// Roll the digits to the new figure on range/mode change
|
||||
// (fires inside the animated transaction above).
|
||||
.contentTransition(.numericText(value: headAvg))
|
||||
if let delta = headlineDelta, delta != 0 {
|
||||
Label(
|
||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||
@@ -389,13 +432,13 @@ struct TrendsView: View {
|
||||
// type-checker's budget.)
|
||||
Chart {
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
areaMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
lineMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
@@ -424,6 +467,9 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 190)
|
||||
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
|
||||
// lines + axis gliding to the rebased series.
|
||||
.animation(.easeInOut(duration: 0.35), value: mode)
|
||||
}
|
||||
|
||||
/// One gradient-filled area band under a single point of a series.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
enum DataRefreshMode: String, CaseIterable, Identifiable {
|
||||
case standard
|
||||
case smart
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .standard: return "Standard"
|
||||
case .smart: return "Smart"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .standard:
|
||||
return "Saves battery. Refreshes price data up to twice daily."
|
||||
case .smart:
|
||||
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
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])
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||
}
|
||||
@@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
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)
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
let meters = FuelStore.roadDistanceMeters(for: s, 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)
|
||||
@@ -664,10 +666,36 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
// 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)
|
||||
}
|
||||
|
||||
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
||||
// Route a road distance to station "a" at pin P1.
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||
// The SAME station id appears with a moved pin (corrected coordinate /
|
||||
// different embedded vs live source): the cached route to P1 must NOT
|
||||
// be served — it belongs to a different location.
|
||||
let moved = station("a", 51.5400, -0.1600)
|
||||
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
|
||||
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||
"road value routed to the old pin leaked onto a different coordinate")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Install identity
|
||||
|
||||
final class InstallIdentityTests: XCTestCase {
|
||||
func testFreshInstallIsStableAfterFirstCall() {
|
||||
// The first call seeds the common install id (local == keychain), so
|
||||
// any subsequent call in the same process must report NOT-fresh. This
|
||||
// holds regardless of persisted keychain/defaults state from prior runs.
|
||||
_ = FuelStore.isFreshInstall()
|
||||
XCTAssertFalse(FuelStore.isFreshInstall())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
enum DataRefreshMode: String, CaseIterable, Identifiable {
|
||||
case standard
|
||||
case smart
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .standard: return "Standard"
|
||||
case .smart: return "Smart"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .standard:
|
||||
return "Saves battery. Refreshes price data up to twice daily."
|
||||
case .smart:
|
||||
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-11
@@ -370,6 +370,8 @@ struct FuelStore {
|
||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
|
||||
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
|
||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
||||
@@ -861,10 +863,24 @@ struct FuelStore {
|
||||
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
||||
}
|
||||
|
||||
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
||||
// twice a day (pull-to-refresh is the manual override).
|
||||
// MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
|
||||
// the same full-dump freshness cap but may probe the tiny mirror pointer
|
||||
// more often in the background and only refresh the full dump if it changed.
|
||||
|
||||
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
|
||||
|
||||
static func loadDataRefreshMode() -> DataRefreshMode {
|
||||
if let raw = loadString(service: dataRefreshModeKey),
|
||||
let mode = DataRefreshMode(rawValue: raw) {
|
||||
return mode
|
||||
}
|
||||
return .standard
|
||||
}
|
||||
|
||||
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
|
||||
saveString(mode.rawValue, service: dataRefreshModeKey)
|
||||
}
|
||||
|
||||
static func loadLastRefresh() -> Date? {
|
||||
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
||||
@@ -877,6 +893,17 @@ struct FuelStore {
|
||||
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||
}
|
||||
|
||||
static func loadLastSmartProbe() -> Date? {
|
||||
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
|
||||
return Date(timeIntervalSince1970: ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveLastSmartProbe(_ date: Date = Date()) {
|
||||
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
|
||||
}
|
||||
|
||||
// MARK: Relay metadata — shown in Settings → About. Written after each
|
||||
// successful full fetch so the About section reflects the live source.
|
||||
|
||||
@@ -929,8 +956,17 @@ struct FuelStore {
|
||||
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||
/// should be skipped (twice-a-day policy).
|
||||
static var isCacheFresh: Bool {
|
||||
isCacheFresh(now: Date())
|
||||
}
|
||||
|
||||
static func isCacheFresh(now: Date) -> Bool {
|
||||
guard let last = loadLastRefresh() else { return false }
|
||||
return Date().timeIntervalSince(last) < refreshInterval
|
||||
return now.timeIntervalSince(last) < refreshInterval
|
||||
}
|
||||
|
||||
static func isSmartProbeDue(now: Date = Date()) -> Bool {
|
||||
guard let last = loadLastSmartProbe() else { return true }
|
||||
return now.timeIntervalSince(last) >= smartProbeInterval
|
||||
}
|
||||
|
||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||
@@ -949,6 +985,43 @@ struct FuelStore {
|
||||
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||
}
|
||||
|
||||
// MARK: Install identity — distinguish a genuinely fresh install (where
|
||||
// onboarding should replay) from later launches of the same install.
|
||||
|
||||
/// Keychain copy of the install id (survives reinstall).
|
||||
private static let installIDKey = "fuelboard.installID"
|
||||
/// Local (app-own container) copy — wiped on reinstall, persists across
|
||||
/// normal launches. Free SideStore accounts have no app-group container,
|
||||
/// so this app-own defaults domain is the reliable "same install" signal.
|
||||
private static let localInstallIDKey = "fuelboard.installID.local"
|
||||
|
||||
/// True when this is a first-ever install OR the app was just reinstalled
|
||||
/// (local install id missing/different from the keychain id). Seeds a fresh
|
||||
/// id into both stores so the next launch within the same install is not a
|
||||
/// "fresh install" any more.
|
||||
static func isFreshInstall() -> Bool {
|
||||
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
|
||||
let remote = loadString(service: installIDKey)
|
||||
if let local, let remote, local == remote { return false }
|
||||
// Fresh / mismatched install (or first launch). Pattern a common id so
|
||||
// subsequent launches of this install are recognised as the same one.
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: localInstallIDKey)
|
||||
saveString(id, service: installIDKey)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Onboarding should present when it hasn't been completed in this install
|
||||
/// OR this is a freshly-installed app (so the walkthrough replays on a new
|
||||
/// install, e.g. after a SideStore reinstall, without re-showing on every
|
||||
/// ordinary launch). The install check always runs (it seeds the id) rather
|
||||
/// than short-circuiting, so a brand-new install is recorded before the
|
||||
/// user ever fills in onboarding.
|
||||
static func shouldShowOnboarding() -> Bool {
|
||||
let fresh = isFreshInstall()
|
||||
return !loadHasCompletedOnboarding() || fresh
|
||||
}
|
||||
|
||||
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||
|
||||
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||
@@ -962,7 +1035,16 @@ struct FuelStore {
|
||||
/// 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]) {
|
||||
/// How far (degrees) a station's stored coordinate may drift from the pin
|
||||
/// a road distance was actually routed to before that cached value is
|
||||
/// treated as belonging to a different station. Guards against one data
|
||||
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
|
||||
/// road distance that was computed for a different coordinate under the
|
||||
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
|
||||
/// catches any real pin change.
|
||||
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||
|
||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||
if let data = try? JSONEncoder().encode(cache) {
|
||||
@@ -980,10 +1062,11 @@ struct FuelStore {
|
||||
|
||||
/// 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? {
|
||||
/// is now / the station's own pin doesn't match the coordinate that was
|
||||
/// routed.
|
||||
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||
guard let cache = loadRoadDistances(),
|
||||
let meters = cache.entries[stationID] else { return nil }
|
||||
let entry = cache.entries[station.id] 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
|
||||
@@ -993,13 +1076,18 @@ struct FuelStore {
|
||||
sin(dLng / 2) * sin(dLng / 2)
|
||||
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||
return meters
|
||||
// Pin fingerprint: never serve a routed value for a coordinate we
|
||||
// didn't actually route to. This is the guard that keeps embedded /
|
||||
// live / cached station sets from injecting each other's road metres.
|
||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||
return entry.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) {
|
||||
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||
return meters / 1000.0
|
||||
}
|
||||
return station.distanceKM(to: userLat, lng2: userLng)
|
||||
@@ -1066,10 +1154,21 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
/// Cached Apple-Maps road distances for nearby stations (see
|
||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres.
|
||||
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||
/// *different* coordinate under the same ID — the guard that stops embedded /
|
||||
/// live / cached station sets cross-contaminating the distance display.
|
||||
struct CachedRoadDistance: Codable {
|
||||
let meters: Double
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
}
|
||||
|
||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||
/// the routed pin.
|
||||
struct RoadDistanceCache: Codable {
|
||||
let sourceLat: Double
|
||||
let sourceLng: Double
|
||||
let updatedAt: TimeInterval
|
||||
let entries: [String: Double]
|
||||
let entries: [String: CachedRoadDistance]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user