Distance confidence - Every distance surface takes the figure AND its "Approx." marker from one FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget, Live Activity, watch, Siri), so a surface can never mark one figure approximate while showing a routed one. Radius filters and Closest ordering test that same displayed distance, so no row can read a distance outside the radius that filtered it (the reported "14.6 mi inside a 10-mile list"). Accurate distances (Settings -> Distances, default off) - Off: the browse layer serves the straight line, marked Approx., for every row, so two installs agree to the metre and the app makes no route requests at all. - On: routed distances for the whole search area, unmarked, served only from a COMPLETE set anchored within 600 m — a partial fill reverts every row at once rather than mixing road and straight-line bases. - Best Value maths sits deliberately outside the toggle: it keeps using measured road routes and its own confidence marking, so money figures never move with the switch. - Fills run only while the toggle is on and the app is frontmost; capacity raised to 600 stations per pass; completeness tolerates pins Apple Maps has no route for, which otherwise left an area permanently incomplete and the whole list stuck on estimates. Performance (the reported slowdown switching to Closest / launching with it) - FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per Closest sort. Memoised, with the setter keeping it in step. - Both keyed sorts decorate their keys — one distance per station, carried as an index instead of copying whole stations into the decorated array — and the comparison baseline plus per-station journeys now come from ONE pass instead of two full scans per tab. The Stations tab also reuses its single sorted list for the count and the TOP badge instead of sorting the country three times. - Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per list, Cheapest 6 ms, Best Value 11 ms. - DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where devicectl eats a leading-dash argument. Tests: 198, 0 failures. Device and simulator Debug builds green.
624 lines
23 KiB
Swift
624 lines
23 KiB
Swift
import SwiftUI
|
||
import Foundation
|
||
import MapKit
|
||
|
||
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
|
||
case e10
|
||
case e5
|
||
case diesel
|
||
|
||
var id: String { rawValue }
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .e10: return "Unleaded"
|
||
case .e5: return "Premium"
|
||
case .diesel: return "Diesel"
|
||
}
|
||
}
|
||
|
||
var shortName: String {
|
||
switch self {
|
||
case .e10: return "E10"
|
||
case .e5: return "E5"
|
||
case .diesel: return "B7"
|
||
}
|
||
}
|
||
|
||
var color: 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
|
||
}
|
||
}
|
||
|
||
var symbol: String { "fuelpump.fill" }
|
||
|
||
var sortRank: Int {
|
||
switch self {
|
||
case .e10: return 0
|
||
case .e5: return 1
|
||
case .diesel: return 2
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum WatchDistanceUnit: String, Codable {
|
||
case miles
|
||
case kilometers
|
||
|
||
func format(km: Double) -> String {
|
||
switch self {
|
||
case .miles:
|
||
return String(format: "%.1f mi", km * 0.621371)
|
||
case .kilometers:
|
||
return String(format: "%.1f km", km)
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum WatchPriceStyle: String, Codable {
|
||
case stationSign
|
||
case poundsPence
|
||
}
|
||
|
||
private struct WatchCoordinate {
|
||
let lat: Double
|
||
let lng: Double
|
||
}
|
||
|
||
private struct WatchStation: Codable, Identifiable {
|
||
let id: String
|
||
let name: String
|
||
let brand: String
|
||
let address: String
|
||
let postcode: String
|
||
let lat: Double
|
||
let lng: Double
|
||
let prices: [WatchFuelType: Double]
|
||
let priceUpdated: TimeInterval?
|
||
|
||
func distanceKM(to location: WatchCoordinate) -> Double {
|
||
let r = 6371.0
|
||
let dLat = (location.lat - lat) * .pi / 180
|
||
let dLng = (location.lng - lng) * .pi / 180
|
||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||
+ cos(lat * .pi / 180) * cos(location.lat * .pi / 180)
|
||
* sin(dLng / 2) * sin(dLng / 2)
|
||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||
}
|
||
}
|
||
|
||
private struct WatchFavouriteEntry: Codable, Identifiable {
|
||
let station: WatchStation
|
||
let fuel: WatchFuelType
|
||
|
||
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
||
}
|
||
|
||
private struct WatchFavouriteRow: Identifiable {
|
||
let id: String
|
||
let name: String
|
||
let priceText: String
|
||
let distanceText: String?
|
||
let isCheapest: Bool
|
||
let latitude: Double
|
||
let longitude: Double
|
||
}
|
||
|
||
private enum WatchRefreshState {
|
||
case idle(lastUpdated: String)
|
||
case stale(lastUpdated: String)
|
||
case checking
|
||
case requested(lastUpdated: String)
|
||
case checkedNoNewData(lastUpdated: String)
|
||
case needsPhone
|
||
case noData
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
|
||
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
|
||
case .checking: return "Checking…"
|
||
case .requested(let lastUpdated): return "Updated · \(lastUpdated)"
|
||
case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)"
|
||
case .needsPhone: return "Refresh on iPhone"
|
||
case .noData: return "No cached prices yet"
|
||
}
|
||
}
|
||
|
||
var tint: Color {
|
||
switch self {
|
||
case .idle: return .secondary
|
||
case .stale: return .yellow
|
||
case .checking: return .yellow
|
||
case .requested: return .green
|
||
case .checkedNoNewData: return .secondary
|
||
case .needsPhone: return .orange
|
||
case .noData: return .secondary
|
||
}
|
||
}
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .idle: return "clock"
|
||
case .stale: return "exclamationmark.circle"
|
||
case .checking: return "arrow.triangle.2.circlepath"
|
||
case .requested: return "checkmark.circle"
|
||
case .checkedNoNewData: return "clock.badge.exclamationmark"
|
||
case .needsPhone: return "iphone"
|
||
case .noData: return "tray"
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum WatchFuelCache {
|
||
static let appGroupSuite = "group.com.apt.fuelboard"
|
||
static let favouritesKey = "fuelboard.favourites"
|
||
static let stationsKey = "fuelboard.stations"
|
||
static let stationsFileName = "fuelboard.stations.json"
|
||
static let fuelKey = "fuelboard.selectedFuel"
|
||
static let distanceUnitKey = "fuelboard.distanceUnit"
|
||
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
||
static let locationKey = "fuelboard.lastLocation"
|
||
static let lastRefreshKey = "fuelboard.lastRefresh"
|
||
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest"
|
||
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled"
|
||
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||
|
||
static func defaults() -> UserDefaults? {
|
||
UserDefaults(suiteName: appGroupSuite)
|
||
}
|
||
|
||
static func loadSelectedFuel() -> WatchFuelType {
|
||
guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else {
|
||
return .e10
|
||
}
|
||
return fuel
|
||
}
|
||
|
||
static func loadDistanceUnit() -> WatchDistanceUnit {
|
||
guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else {
|
||
return .miles
|
||
}
|
||
return unit
|
||
}
|
||
|
||
static func loadPriceStyle() -> WatchPriceStyle {
|
||
guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else {
|
||
return .stationSign
|
||
}
|
||
return style
|
||
}
|
||
|
||
static func loadLocation() -> WatchCoordinate? {
|
||
guard let raw = defaults()?.string(forKey: locationKey) else { return nil }
|
||
let parts = raw.split(separator: ",").compactMap { Double($0) }
|
||
guard parts.count >= 2 else { return nil }
|
||
return WatchCoordinate(lat: parts[0], lng: parts[1])
|
||
}
|
||
|
||
static func loadLastRefresh() -> Date? {
|
||
guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil }
|
||
return Date(timeIntervalSince1970: ts)
|
||
}
|
||
|
||
static func isCacheFresh(lastRefresh: Date?) -> Bool {
|
||
guard let lastRefresh else { return false }
|
||
return Date().timeIntervalSince(lastRefresh) < refreshInterval
|
||
}
|
||
|
||
static func requestRefresh(_ date: Date = Date()) {
|
||
defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey)
|
||
}
|
||
|
||
static func loadRefreshRequest() -> Date? {
|
||
guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil }
|
||
return Date(timeIntervalSince1970: ts)
|
||
}
|
||
|
||
static func loadRefreshHandled() -> Date? {
|
||
guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil }
|
||
return Date(timeIntervalSince1970: ts)
|
||
}
|
||
|
||
static func wasRefreshHandled(since requestDate: Date) -> Bool {
|
||
guard let handled = loadRefreshHandled() else { return false }
|
||
return handled >= requestDate
|
||
}
|
||
|
||
static func loadStations() -> [WatchStation] {
|
||
let fileData: Data?
|
||
if let container = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: appGroupSuite
|
||
) {
|
||
let url = container.appendingPathComponent(stationsFileName)
|
||
fileData = try? Data(contentsOf: url)
|
||
} else {
|
||
fileData = nil
|
||
}
|
||
let data = fileData ?? defaults()?.data(forKey: stationsKey)
|
||
guard let data,
|
||
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
||
return []
|
||
}
|
||
return stations
|
||
}
|
||
|
||
static func loadFavourites() -> [WatchFavouriteEntry] {
|
||
guard let data = defaults()?.data(forKey: favouritesKey),
|
||
let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else {
|
||
return []
|
||
}
|
||
return favourites
|
||
}
|
||
|
||
static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] {
|
||
favourites.map { favourite in
|
||
guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite }
|
||
return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel)
|
||
}
|
||
}
|
||
|
||
static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? {
|
||
var best: WatchFavouriteEntry?
|
||
for favourite in favourites where favourite.fuel == fuel {
|
||
guard let price = favourite.station.prices[fuel] else { continue }
|
||
guard let current = best, let currentPrice = current.station.prices[fuel] else {
|
||
best = favourite
|
||
continue
|
||
}
|
||
if price < currentPrice {
|
||
best = favourite
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
static func priceText(_ pence: Double, style: WatchPriceStyle) -> String {
|
||
switch style {
|
||
case .stationSign:
|
||
return String(format: "%.1f", pence)
|
||
case .poundsPence:
|
||
let tenths = Int((pence * 10).rounded())
|
||
let whole = tenths / 1000
|
||
let major = (tenths % 1000) / 10
|
||
let minor = tenths % 10
|
||
let superscripts: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||
return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
|
||
@State private var refreshState: WatchRefreshState = .noData
|
||
@State private var favourites: [WatchFavouriteEntry] = []
|
||
@State private var stations: [WatchStation] = []
|
||
@State private var lastLocation: WatchCoordinate?
|
||
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
|
||
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
|
||
|
||
private var displayedFuels: [WatchFuelType] {
|
||
let fuels = Set(favourites.map(\.fuel))
|
||
let sorted = fuels.sorted { $0.sortRank < $1.sortRank }
|
||
return sorted.isEmpty ? [fuel] : sorted
|
||
}
|
||
|
||
private var hasAnyFavourites: Bool {
|
||
!favourites.isEmpty
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if hasAnyFavourites {
|
||
TabView(selection: $fuel) {
|
||
ForEach(displayedFuels) { fuelCase in
|
||
fuelPage(for: fuelCase)
|
||
.tag(fuelCase)
|
||
}
|
||
}
|
||
.tabViewStyle(.verticalPage)
|
||
} else {
|
||
emptyStateView
|
||
}
|
||
}
|
||
.onAppear {
|
||
reloadFromCache()
|
||
WatchSyncManager.shared.requestSnapshot()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in
|
||
reloadFromCache()
|
||
}
|
||
.onChange(of: scenePhase) { _, newPhase in
|
||
if newPhase == .active {
|
||
reloadFromCache()
|
||
WatchSyncManager.shared.requestSnapshot()
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func fuelPage(for fuelCase: WatchFuelType) -> some View {
|
||
let fuelFavourites = favouritesForFuel(fuelCase)
|
||
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||
let rows = rows(for: fuelCase)
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
header(for: fuelCase)
|
||
|
||
if let cheapest {
|
||
cheapestCard(for: fuelCase, favourite: cheapest)
|
||
}
|
||
|
||
statusRow
|
||
|
||
Button {
|
||
runCheckNow()
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: refreshState.icon)
|
||
Text("Check now")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(fuelCase.color)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Favourites")
|
||
.font(.headline)
|
||
|
||
ForEach(rows) { row in
|
||
favouriteRow(row, fuel: fuelCase)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 12)
|
||
}
|
||
}
|
||
|
||
private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] {
|
||
favourites.filter { $0.fuel == fuelCase }
|
||
}
|
||
|
||
private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] {
|
||
let fuelFavourites = favouritesForFuel(fuelCase)
|
||
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||
|
||
return fuelFavourites.map { favourite in
|
||
// watchOS has no routed road distances (no MKDirections here and no
|
||
// shared route cache), so EVERY watch figure is the straight-line
|
||
// fallback and must say so — same rule as the app.
|
||
let distanceText = lastLocation.map { "Approx. " + distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
|
||
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
|
||
return WatchFavouriteRow(
|
||
id: favourite.id,
|
||
name: favourite.station.name,
|
||
priceText: priceText,
|
||
distanceText: distanceText,
|
||
isCheapest: favourite.id == cheapest?.id,
|
||
latitude: favourite.station.lat,
|
||
longitude: favourite.station.lng
|
||
)
|
||
}
|
||
}
|
||
|
||
private func header(for fuel: WatchFuelType) -> some View {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Image(systemName: fuel.symbol)
|
||
.foregroundStyle(fuel.color)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("FuelBoard")
|
||
.font(.headline)
|
||
Text(fuel.displayName)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var emptyStateView: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Image(systemName: "fuelpump.fill")
|
||
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||
Text("FuelBoard")
|
||
.font(.headline)
|
||
Spacer()
|
||
}
|
||
|
||
statusRow
|
||
|
||
Button {
|
||
runCheckNow()
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: refreshState.icon)
|
||
Text("Check now")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("No favourites yet")
|
||
.font(.headline)
|
||
Text("Add favourites on iPhone and they’ll appear here.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 12)
|
||
}
|
||
}
|
||
|
||
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
|
||
Button {
|
||
openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Cheapest favourite")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(favourite.station.name)
|
||
.font(.headline)
|
||
.lineLimit(2)
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—")
|
||
.font(.title3)
|
||
.fontWeight(.bold)
|
||
Spacer()
|
||
if let location = lastLocation {
|
||
Text("Approx. " + distanceUnit.format(km: favourite.station.distanceKM(to: location)))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
|
||
private var statusRow: some View {
|
||
Label(refreshState.label, systemImage: refreshState.icon)
|
||
.font(.caption)
|
||
.foregroundStyle(refreshState.tint)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
|
||
Button {
|
||
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
Text(favourite.name)
|
||
.font(.caption)
|
||
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
||
.lineLimit(1)
|
||
if favourite.isCheapest {
|
||
Text("TOP")
|
||
.font(.system(size: 9, weight: .bold))
|
||
.padding(.horizontal, 4)
|
||
.padding(.vertical, 2)
|
||
.background(fuel.color.opacity(0.18), in: Capsule())
|
||
}
|
||
}
|
||
if let distanceText = favourite.distanceText {
|
||
Text(distanceText)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
Text(favourite.priceText)
|
||
.font(.callout.monospacedDigit())
|
||
.fontWeight(.semibold)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
}
|
||
|
||
private func reloadFromCache() {
|
||
stations = WatchFuelCache.loadStations()
|
||
favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations)
|
||
distanceUnit = WatchFuelCache.loadDistanceUnit()
|
||
priceStyle = WatchFuelCache.loadPriceStyle()
|
||
lastLocation = WatchFuelCache.loadLocation()
|
||
refreshState = statusFromCache()
|
||
|
||
let availableFuels = Set(favourites.map(\.fuel))
|
||
if !availableFuels.isEmpty, !availableFuels.contains(fuel) {
|
||
fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10
|
||
}
|
||
}
|
||
|
||
private func statusFromCache() -> WatchRefreshState {
|
||
let hasUsablePrices = !stations.isEmpty
|
||
|| favourites.contains { favourite in favourite.station.prices[fuel] != nil }
|
||
guard hasUsablePrices else { return .noData }
|
||
guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
|
||
let formatted = relativeRefreshText(for: lastRefresh)
|
||
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
|
||
? .idle(lastUpdated: formatted)
|
||
: .stale(lastUpdated: formatted)
|
||
}
|
||
|
||
private func runCheckNow() {
|
||
refreshState = .checking
|
||
let previousLastRefresh = WatchFuelCache.loadLastRefresh()
|
||
let requestDate = Date()
|
||
WatchFuelCache.requestRefresh(requestDate)
|
||
WatchSyncManager.shared.requestRefresh(at: requestDate)
|
||
Task {
|
||
for _ in 0..<12 {
|
||
try? await Task.sleep(for: .seconds(1))
|
||
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
|
||
await MainActor.run {
|
||
reloadFromCache()
|
||
let currentLastRefresh = WatchFuelCache.loadLastRefresh()
|
||
if let currentLastRefresh,
|
||
currentLastRefresh >= requestDate,
|
||
currentLastRefresh > (previousLastRefresh ?? .distantPast) {
|
||
refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||
} else if let currentLastRefresh {
|
||
refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||
} else {
|
||
refreshState = .noData
|
||
}
|
||
}
|
||
return
|
||
}
|
||
}
|
||
await MainActor.run {
|
||
reloadFromCache()
|
||
refreshState = .needsPhone
|
||
}
|
||
}
|
||
}
|
||
|
||
private func relativeRefreshText(for date: Date) -> String {
|
||
let formatter = RelativeDateTimeFormatter()
|
||
formatter.unitsStyle = .short
|
||
return formatter.localizedString(for: date, relativeTo: Date())
|
||
}
|
||
|
||
private func openMaps(latitude: Double, longitude: Double) {
|
||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||
let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||
_ = MKMapItem.openMaps(
|
||
with: [MKMapItem.forCurrentLocation(), destination],
|
||
launchOptions: [
|
||
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||
]
|
||
)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
ContentView()
|
||
}
|