Cache-first refresh policy: auto-fetch at most twice a day (12h gate), no fetch on launch/foreground/location/fuel-switch, pull-to-refresh forces; geofences still follow location
This commit is contained in:
@@ -87,7 +87,8 @@ struct ContentView: View {
|
|||||||
location: location,
|
location: location,
|
||||||
radiusKM: alertsRadius,
|
radiusKM: alertsRadius,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
onToggleFavourite: toggleFavourite
|
onToggleFavourite: toggleFavourite,
|
||||||
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
@@ -120,7 +122,7 @@ struct ContentView: View {
|
|||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
Task { await refresh() }
|
// No network fetch on foreground — pull-to-refresh is the override.
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
}
|
}
|
||||||
@@ -130,13 +132,15 @@ struct ContentView: View {
|
|||||||
location = newLocation
|
location = newLocation
|
||||||
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
Task { await refresh() }
|
// Geofences follow the user's position, but the station list is
|
||||||
|
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||||||
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedFuel) { _, _ in
|
.onChange(of: selectedFuel) { _, _ in
|
||||||
// Re-fetch so the station set matches the selected fuel (relay
|
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||||
// filters by grade); all prices still come back in one response.
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
Task { await refresh() }
|
|
||||||
}
|
}
|
||||||
.onChange(of: alertsEnabled) { _, newValue in
|
.onChange(of: alertsEnabled) { _, newValue in
|
||||||
FuelStore.saveAlertsEnabled(newValue)
|
FuelStore.saveAlertsEnabled(newValue)
|
||||||
@@ -166,13 +170,19 @@ struct ContentView: View {
|
|||||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refresh() async {
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
||||||
|
/// `force` is true (pull-to-refresh is the manual override).
|
||||||
|
private func refresh(force: Bool = false) async {
|
||||||
|
if !force, FuelStore.isCacheFresh {
|
||||||
|
return // data already fresh — skip network entirely
|
||||||
|
}
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel)
|
let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel)
|
||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
|
FuelStore.saveLastRefresh()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct StationsView: View {
|
|||||||
let radiusKM: Double
|
let radiusKM: Double
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
var onToggleFavourite: (FuelStation) -> Void = { _ in }
|
var onToggleFavourite: (FuelStation) -> Void = { _ in }
|
||||||
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -130,6 +131,10 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
// Manual override for the twice-a-day cache policy.
|
||||||
|
await onRefresh()
|
||||||
|
}
|
||||||
.navigationTitle("FuelBoard")
|
.navigationTitle("FuelBoard")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ struct FuelStore {
|
|||||||
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
||||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
|
|
||||||
// MARK: Stations
|
// MARK: Stations
|
||||||
|
|
||||||
@@ -280,6 +281,29 @@ struct FuelStore {
|
|||||||
saveString(String(radius), service: alertsRadiusKey)
|
saveString(String(radius), service: alertsRadiusKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
||||||
|
// twice a day (pull-to-refresh is the manual override).
|
||||||
|
|
||||||
|
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||||
|
|
||||||
|
static func loadLastRefresh() -> Date? {
|
||||||
|
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveLastRefresh(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
|
/// should be skipped (twice-a-day policy).
|
||||||
|
static var isCacheFresh: Bool {
|
||||||
|
guard let last = loadLastRefresh() else { return false }
|
||||||
|
return Date().timeIntervalSince(last) < refreshInterval
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
|
|||||||
Reference in New Issue
Block a user