Author SHA1 Message Date
FuelBoard Contributor c9102217b9 Fix iOS deprecation warnings 2026-08-30 12:58:32 +01:00
FuelBoard Contributor 457816ec61 Add Smart data checking mode 2026-08-29 13:48:08 +01:00
11 changed files with 235 additions and 12 deletions
+13 -6
View File
@@ -113,12 +113,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
action in the Alerts tab that forces a fetch and immediately re-evaluates the action in the Alerts tab that forces a fetch and immediately re-evaluates the
cheapest-favourite alert. Copy should set expectation honestly: standard cheapest-favourite alert. Copy should set expectation honestly: standard
mode checks when FuelBoard refreshes prices (up to twice daily). mode checks when FuelBoard refreshes prices (up to twice daily).
- [ ] **Price-drop alerts: optional frequent-check mode**if the app ever - [ ] **Data checking mode: Standard vs Smart**keep the current cache-first
relaxes the current "twice daily max" data policy, expose this as a clearly / 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
separate mode rather than a freeform frequency slider. Example framing: `Smart` should try lightweight background checks of the mirror pointer
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks), (`latest.json` / `data_updated`) more often, and only download the full price
with conservative caps (e.g. every 46 h, not hourly) and copy that does not dataset when a newer snapshot is available. Design goal: better freshness
promise exact timing under iOS background scheduling. 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 - [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list). (currently `refreshable` fires but no visible progress in the row list).
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`, - [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
+5
View File
@@ -41,8 +41,13 @@
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.apt.fuelboard.smart-refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string>
<string>location</string> <string>location</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
+5
View File
@@ -16,6 +16,7 @@ struct ContentView: View {
@State private var stations: [FuelStation] = FuelStore.loadStations() @State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@@ -365,6 +366,7 @@ struct ContentView: View {
if !showing, FuelStore.loadHasCompletedOnboarding() { if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
installLocationUpdateHook() installLocationUpdateHook()
SmartDataRefreshScheduler.scheduleNextIfNeeded()
ensureBackgroundMotionTrackingIfNeeded() ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -380,6 +382,7 @@ struct ContentView: View {
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() { if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
} }
SmartDataRefreshScheduler.scheduleNextIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity() updateLiveActivity()
@@ -787,6 +790,7 @@ struct ContentView: View {
tipStore: tipStore, tipStore: tipStore,
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle, priceDisplayStyle: $priceDisplayStyle,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
@@ -849,6 +853,7 @@ struct ContentView: View {
stations = fetched stations = fetched
FuelStore.saveStations(fetched) FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh() FuelStore.saveLastRefresh()
FuelStore.saveLastSmartProbe()
// Persist envelope metadata (source, station count, GOV.UK // Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section the // dataset update time) for the Settings About section the
// live chain records whichever leg served the fetch. // live chain records whichever leg served the fetch.
+4
View File
@@ -1,4 +1,5 @@
import ActivityKit import ActivityKit
import BackgroundTasks
import SwiftUI import SwiftUI
@main @main
@@ -22,6 +23,9 @@ struct FuelBoardApp: App {
ContentView() ContentView()
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
} }
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
await SmartDataRefreshCoordinator.runBackgroundProbe()
}
} }
#if DEBUG #if DEBUG
+3 -1
View File
@@ -73,7 +73,9 @@ enum LiveActivityManager {
currentStartDate = nil currentStartDate = nil
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
} else { } else {
Task { await current.update(using: state) } Task {
await current.update(.init(state: state, staleDate: nil))
}
} }
} else { } else {
start(attributes: attributes, state: state) start(attributes: attributes, state: state)
+6 -2
View File
@@ -73,8 +73,12 @@ enum RoadDistanceService {
/// Driving distance (metres) between two coordinates via Apple Maps routing. /// Driving distance (metres) between two coordinates via Apple Maps routing.
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? { private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
let request = MKDirections.Request() let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from)) request.source = MKMapItem(location: CLLocation(latitude: from.latitude,
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to)) longitude: from.longitude),
address: nil)
request.destination = MKMapItem(location: CLLocation(latitude: to.latitude,
longitude: to.longitude),
address: nil)
request.transportType = .automobile request.transportType = .automobile
request.requestsAlternateRoutes = false request.requestsAlternateRoutes = false
do { do {
+24
View File
@@ -18,6 +18,7 @@ struct SettingsView: View {
@ObservedObject var tipStore: TipStore @ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle @Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 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).") 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 { Section {
Button { Button {
onShowOnboarding() onShowOnboarding()
+88
View File
@@ -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
}
}
}
@@ -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."
}
}
}
+24
View File
@@ -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."
}
}
}
+39 -3
View File
@@ -370,6 +370,8 @@ struct FuelStore {
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) 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 relaySourceKey = "fuelboard.relaySource" // String "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String station count static let stationCountKey = "fuelboard.stationCount" // String station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String govUK dataset update time static let dataUpdatedKey = "fuelboard.dataUpdated" // String govUK dataset update time
@@ -861,10 +863,24 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
} }
// MARK: Refresh policy data is cached; the app only auto-refreshes // MARK: Refresh policy Standard is cache-first/twice-daily; Smart keeps
// twice a day (pull-to-refresh is the manual override). // 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 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? { static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) { if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
@@ -877,6 +893,17 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) 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 // MARK: Relay metadata shown in Settings About. Written after each
// successful full fetch so the About section reflects the live source. // 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 /// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy). /// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool { static var isCacheFresh: Bool {
isCacheFresh(now: Date())
}
static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false } 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 // MARK: Onboarding the app shows the intro screen on first launch only