import SwiftUI import Foundation 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 } private enum WatchRefreshState { case idle(lastUpdated: String) case stale(lastUpdated: String) case checking case requested(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 "Requested · \(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 .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 .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 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] { guard let data = defaults()?.data(forKey: stationsKey), 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() var body: some View { TabView(selection: $fuel) { ForEach(WatchFuelType.allCases) { fuelCase in fuelPage(for: fuelCase) .tag(fuelCase) } } .tabViewStyle(.verticalPage) .onAppear { reloadFromCache() WatchSyncManager.shared.requestSnapshot() } .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in reloadFromCache() } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { reloadFromCache() } } } @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) } else { emptyCheapestCard(for: fuelCase) } 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) if rows.isEmpty { Text("No favourites for \(fuelCase.displayName.lowercased()) yet.") .font(.caption) .foregroundStyle(.secondary) } else { 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 let distanceText = lastLocation.map { 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 ) } } 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 func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View { 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(distanceUnit.format(km: favourite.station.distanceKM(to: location))) .font(.caption) .foregroundStyle(.secondary) } } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } private func emptyCheapestCard(for fuel: WatchFuelType) -> some View { VStack(alignment: .leading, spacing: 6) { Text("Cheapest favourite") .font(.caption2) .foregroundStyle(.secondary) Text("No favourite saved") .font(.headline) Text("Add favourites on iPhone to see them here.") .font(.caption) .foregroundStyle(.secondary) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .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 { 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) .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 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 label = WatchFuelCache.loadLastRefresh().map(relativeRefreshText(for:)) ?? "just now" refreshState = .requested(lastUpdated: label) } 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()) } } #Preview { ContentView() }