Top and Cheapest were two words for one state: both meant "the lowest price among the stations on screen", differing only in the candidate set (radius vs Favourites). Which word a surface used had drifted by device — the phone's rows said Cheapest while the watch said Top for the identical state. Top is gone from the enum, the Key legend, the badge and the watch's hand-rolled label; Cheapest survives because it names the measure and is already the word the favourites copy uses. Closest is now a real station label rather than a verdict. StationRecommendationBadge gains .closest with a total precedence — Best value, then Cheapest, then Closest — because a row has room for one label and proximity is the weakest claim: a short drive says nothing about the price, and unlike the other two it reports no saving at all. The badge sits above the price, where the other two sit. The closest row's verdict capsule is suppressed: its only word for that state was "Closest", so printing it below as well would state the same fact twice, three lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged. "Closest station" becomes "Closest", read from the single FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key cannot word it differently. The Key legend renders all three station labels with the shared StationLabelBadge; the old KeyStationLabelIndicator, which special-cased the closest station to a wider verdict pill so it read as a different class of thing from the two above it, is deleted. One closest icon everywhere. The listing and detail verdict symbols used location.circle and location.circle.fill for the state the Key taught as location.north.fill; both now read StationLabelKind.closest.symbol, so the three surfaces resolve one glyph from one place. The detail sheet gains the Closest capsule below the map, ordered to match the Key. Favourites gains the nearest favourite as its Closest, measured on the same one-way distance its rows print and restricted to favourites with a price for the active fuel — matching the radius tab, where the closest station is by definition the savings baseline. closestStationID flows from the one scaffold baseline the savings lookup already compares against, so a row's badge and the reference station cannot disagree. No savings calculation, threshold, ranking, eligibility or station-selection logic changed. The Key sheet's detent is now bound rather than fixed, so dragging it up to read the Station labels section sticks instead of snapping back; a QA launch argument opens it at full height, since the 0.6 detent puts that section below the fold on every device. That argument is read unconditionally, unlike the other harness hooks which are #if DEBUG-gated. Tests: 278 pass, 9 new — the label precedence ladder across every combination, capsule order in the sheet, Closest alone, and the retired label pinned absent.
628 lines
23 KiB
Swift
628 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 / 1.609344)
|
||
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 {
|
||
// The same WORD as every other surface, not just the same
|
||
// capitalisation. The phone's rows call this state
|
||
// "Cheapest"; a watch that said "Top" made one state look
|
||
// like two different achievements.
|
||
Text("Cheapest")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.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()
|
||
}
|