Merge main into feature/watch-app
This commit is contained in:
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||
}
|
||||
if let location = entry.location {
|
||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if let location = entry.location {
|
||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
+151
-4
@@ -8,6 +8,7 @@
|
||||
// keychain → app-group defaults → fallback.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Security
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
|
||||
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
|
||||
/// definition.
|
||||
extension FuelType {
|
||||
var tintColor: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppIntents)
|
||||
extension FuelType: AppEnum {}
|
||||
#endif
|
||||
@@ -949,15 +964,127 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||
// (a test button in the Alerts tab re-opens it). Stored in the app group
|
||||
// so the widget can see it too if ever needed.
|
||||
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
|
||||
// (with an app-group mirror) for the same reason as favourites/distance
|
||||
// unit: free SideStore accounts don't provision the app-group container,
|
||||
// so an app-group-only flag silently fails to save AND reloads as false,
|
||||
// making onboarding re-appear on every launch. Keychain survives reinstall
|
||||
// and is shared with the extension.
|
||||
|
||||
static func loadHasCompletedOnboarding() -> Bool {
|
||||
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
|
||||
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
|
||||
}
|
||||
|
||||
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
||||
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||
}
|
||||
|
||||
// MARK: Install identity — distinguish a genuinely fresh install (where
|
||||
// onboarding should replay) from later launches of the same install.
|
||||
|
||||
/// Keychain copy of the install id (survives reinstall).
|
||||
private static let installIDKey = "fuelboard.installID"
|
||||
/// Local (app-own container) copy — wiped on reinstall, persists across
|
||||
/// normal launches. Free SideStore accounts have no app-group container,
|
||||
/// so this app-own defaults domain is the reliable "same install" signal.
|
||||
private static let localInstallIDKey = "fuelboard.installID.local"
|
||||
|
||||
/// True when this is a first-ever install OR the app was just reinstalled
|
||||
/// (local install id missing/different from the keychain id). Seeds a fresh
|
||||
/// id into both stores so the next launch within the same install is not a
|
||||
/// "fresh install" any more.
|
||||
static func isFreshInstall() -> Bool {
|
||||
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
|
||||
let remote = loadString(service: installIDKey)
|
||||
if let local, let remote, local == remote { return false }
|
||||
// Fresh / mismatched install (or first launch). Pattern a common id so
|
||||
// subsequent launches of this install are recognised as the same one.
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: localInstallIDKey)
|
||||
saveString(id, service: installIDKey)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Onboarding should present when it hasn't been completed in this install
|
||||
/// OR this is a freshly-installed app (so the walkthrough replays on a new
|
||||
/// install, e.g. after a SideStore reinstall, without re-showing on every
|
||||
/// ordinary launch). The install check always runs (it seeds the id) rather
|
||||
/// than short-circuiting, so a brand-new install is recorded before the
|
||||
/// user ever fills in onboarding.
|
||||
static func shouldShowOnboarding() -> Bool {
|
||||
let fresh = isFreshInstall()
|
||||
return !loadHasCompletedOnboarding() || fresh
|
||||
}
|
||||
|
||||
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||
|
||||
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
|
||||
/// SideStore accounts where the app-group container isn't provisioned) so
|
||||
/// the widget extension can read it too. Widget + Live Activity prefer
|
||||
/// these over straight-line haversine for the displayed distance.
|
||||
static let roadDistancesKey = "fuelboard.roadDistances"
|
||||
|
||||
/// How far (metres) the cache's source location may be from the current
|
||||
/// user position before a cached road distance is treated as stale.
|
||||
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||
|
||||
/// How far (degrees) a station's stored coordinate may drift from the pin
|
||||
/// a road distance was actually routed to before that cached value is
|
||||
/// treated as belonging to a different station. Guards against one data
|
||||
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
|
||||
/// road distance that was computed for a different coordinate under the
|
||||
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
|
||||
/// catches any real pin change.
|
||||
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||
|
||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||
if let data = try? JSONEncoder().encode(cache) {
|
||||
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||
guard let raw = loadString(service: roadDistancesKey),
|
||||
let data = Data(base64Encoded: raw),
|
||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||
else { return nil }
|
||||
return cache
|
||||
}
|
||||
|
||||
/// Cached road distance (metres) to a station from the user's location, or
|
||||
/// nil when not cached / the cache was built too far from where the user
|
||||
/// is now / the station's own pin doesn't match the coordinate that was
|
||||
/// routed.
|
||||
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||
guard let cache = loadRoadDistances(),
|
||||
let entry = cache.entries[station.id] else { return nil }
|
||||
// The cache is only valid near the location it was built from.
|
||||
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||
let r = 6371000.0
|
||||
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
|
||||
sin(dLng / 2) * sin(dLng / 2)
|
||||
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||
// Pin fingerprint: never serve a routed value for a coordinate we
|
||||
// didn't actually route to. This is the guard that keeps embedded /
|
||||
// live / cached station sets from injecting each other's road metres.
|
||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||
return entry.meters
|
||||
}
|
||||
|
||||
/// Distance (km) to display for a station: cached ROAD distance when
|
||||
/// available (matches Apple Maps), else straight-line haversine.
|
||||
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||
return meters / 1000.0
|
||||
}
|
||||
return station.distanceKM(to: userLat, lng2: userLng)
|
||||
}
|
||||
|
||||
// MARK: Low-level keychain helpers
|
||||
@@ -1019,3 +1146,23 @@ struct FuelStore {
|
||||
loadString(service: "widget.diag.\(intentType)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached Apple-Maps road distances for nearby stations (see
|
||||
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||
/// *different* coordinate under the same ID — the guard that stops embedded /
|
||||
/// live / cached station sets cross-contaminating the distance display.
|
||||
struct CachedRoadDistance: Codable {
|
||||
let meters: Double
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
}
|
||||
|
||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||
/// the routed pin.
|
||||
struct RoadDistanceCache: Codable {
|
||||
let sourceLat: Double
|
||||
let sourceLng: Double
|
||||
let updatedAt: TimeInterval
|
||||
let entries: [String: CachedRoadDistance]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user