Files
fuelboard/FuelBoardWatch Watch App/ContentView.swift
T

610 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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()
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
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,
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 theyll 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(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()
}