Restore smart settings and sync watch favourites
This commit is contained in:
@@ -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()
|
||||
@@ -275,6 +276,7 @@ struct ContentView: View {
|
||||
}
|
||||
if !favs.isEmpty {
|
||||
FuelStore.saveFavourites(favs)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||
@@ -396,6 +398,7 @@ struct ContentView: View {
|
||||
if let newLocation {
|
||||
location = newLocation
|
||||
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Geofences follow the user's position, but the station list is
|
||||
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||||
@@ -414,6 +417,7 @@ struct ContentView: View {
|
||||
.onChange(of: selectedFuel) { _, _ in
|
||||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
.onChange(of: stationLimit) { _, newValue in
|
||||
// Distance filter is LOCAL math now — the cache holds the full-UK
|
||||
@@ -790,6 +794,7 @@ struct ContentView: View {
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
testAlertResult: monitor.lastTestResult,
|
||||
@@ -818,6 +823,7 @@ struct ContentView: View {
|
||||
favourites.append(key)
|
||||
}
|
||||
FuelStore.saveFavourites(favourites)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
@@ -829,6 +835,7 @@ struct ContentView: View {
|
||||
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
||||
favourites = newOrder
|
||||
FuelStore.saveFavourites(favourites)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
@@ -860,6 +867,7 @@ struct ContentView: View {
|
||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||
FuelStore.markWatchRefreshHandled()
|
||||
}
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
// 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,9 +1,12 @@
|
||||
import ActivityKit
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FuelBoardApp: App {
|
||||
init() {
|
||||
WatchSyncManager.shared.activate()
|
||||
SmartDataRefreshScheduler.register()
|
||||
#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
|
||||
@@ -21,6 +24,12 @@ struct FuelBoardApp: App {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
|
||||
await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -103,6 +104,7 @@ struct SettingsView: View {
|
||||
.onChange(of: distanceUnit) { _, newValue in
|
||||
FuelStore.saveDistanceUnit(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
Picker("Price display", selection: $priceDisplayStyle) {
|
||||
ForEach(PriceDisplayStyle.allCases) { style in
|
||||
@@ -113,6 +115,7 @@ struct SettingsView: View {
|
||||
.onChange(of: priceDisplayStyle) { _, newValue in
|
||||
FuelStore.savePriceDisplayStyle(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
} header: {
|
||||
Text("Units")
|
||||
@@ -120,6 +123,30 @@ 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()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
await WatchSyncManager.shared.pushSnapshot()
|
||||
if let meta = LiveChainProvider.latestMeta {
|
||||
FuelStore.saveRelayMeta(meta)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchSyncManager()
|
||||
|
||||
private var pendingSnapshotPush = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
pendingSnapshotPush = true
|
||||
let session = WCSession.default
|
||||
if session.delegate !== self {
|
||||
session.delegate = self
|
||||
}
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func pushSnapshot() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else {
|
||||
pendingSnapshotPush = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.updateApplicationContext(snapshotContext())
|
||||
pendingSnapshotPush = false
|
||||
} catch {
|
||||
print("WATCH-SYNC push failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotContext() -> [String: Any] {
|
||||
var context: [String: Any] = [:]
|
||||
|
||||
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
|
||||
context[FuelStore.favouritesKey] = favourites
|
||||
}
|
||||
|
||||
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
|
||||
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
|
||||
context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue
|
||||
|
||||
if let lastRefresh = FuelStore.loadLastRefresh() {
|
||||
context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970)
|
||||
}
|
||||
|
||||
if let handled = FuelStore.loadWatchRefreshHandled() {
|
||||
context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970)
|
||||
}
|
||||
|
||||
if let location = FuelStore.loadLocationWithDate() {
|
||||
context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)"
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||
if let error {
|
||||
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if activationState == .activated, self.pendingSnapshotPush {
|
||||
self.pushSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
Task { @MainActor in
|
||||
WCSession.default.activate()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
|
||||
handleIncomingRefreshRequest(userInfo)
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
|
||||
handleIncomingRefreshRequest(message)
|
||||
}
|
||||
|
||||
private nonisolated func handleIncomingRefreshRequest(_ payload: [String: Any]) {
|
||||
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
|
||||
let timestamp = TimeInterval(raw) else { return }
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
Task { @MainActor in
|
||||
FuelStore.requestWatchRefresh(date)
|
||||
NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user