diff --git a/gastrack/Models/Station.swift b/gastrack/Models/Station.swift index bb9a243..146fca0 100644 --- a/gastrack/Models/Station.swift +++ b/gastrack/Models/Station.swift @@ -13,7 +13,7 @@ struct Price: Codable, Identifiable { } } -struct Station: Codable, Identifiable { +struct Station: Codable, Identifiable, Sendable { let id: String let name: String let lat: Double @@ -35,6 +35,20 @@ struct Station: Codable, Identifiable { } } +struct HealthResponse: Codable { + let ok: Bool + let cachedStations: Int + let oldestFetch: String? + let newestFetch: String? + + enum CodingKeys: String, CodingKey { + case ok + case cachedStations = "cached_stations" + case oldestFetch = "oldest_fetch" + case newestFetch = "newest_fetch" + } +} + struct PrefetchResponse: Codable { struct Bbox: Codable { let minLat: Double diff --git a/gastrack/Services/APIClient.swift b/gastrack/Services/APIClient.swift index b408b89..14846cf 100644 --- a/gastrack/Services/APIClient.swift +++ b/gastrack/Services/APIClient.swift @@ -75,6 +75,10 @@ final class APIClient: ObservableObject { return try await get(comps.url!) } + func fetchHealth() async throws -> HealthResponse { + return try await get(try endpoint("/health")) + } + func fetchEIAAverages() async throws -> [EIAAverage] { return try await get(try endpoint("/eia/averages")) } diff --git a/gastrack/Services/StationStore.swift b/gastrack/Services/StationStore.swift index 5e7e967..632be41 100644 --- a/gastrack/Services/StationStore.swift +++ b/gastrack/Services/StationStore.swift @@ -1,18 +1,97 @@ import Combine import CoreLocation +import GRDB import MapKit +// MARK: - GRDB conformance for Station + +extension Station: FetchableRecord { + nonisolated init(row: Row) throws { + let pricesJson: String = row["prices_json"] + let prices = (try? JSONDecoder().decode([Price].self, from: Data(pricesJson.utf8))) ?? [] + self.init( + id: row["id"], + name: row["name"], + lat: row["lat"], + lng: row["lng"], + address: row["address"], + city: row["city"], + state: row["state"], + zip: row["zip"], + prices: prices, + fetchedAt: row["fetched_at"] + ) + } +} + +extension Station: PersistableRecord { + static let databaseTableName = "stations" + + nonisolated func encode(to container: inout PersistenceContainer) throws { + container["id"] = id + container["name"] = name + container["lat"] = lat + container["lng"] = lng + container["address"] = address + container["city"] = city + container["state"] = state + container["zip"] = zip + container["prices_json"] = String(data: (try? JSONEncoder().encode(prices)) ?? Data(), encoding: .utf8) ?? "[]" + container["fetched_at"] = fetchedAt + } +} + +// MARK: - Store + @MainActor final class StationStore: ObservableObject { static let shared = StationStore() @Published private(set) var byId: [String: Station] = [:] + private let db: DatabaseQueue + + init() { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + db = (try? DatabaseQueue(path: dir.appendingPathComponent("stations.sqlite").path)) ?? (try! DatabaseQueue()) + + try? db.write { d in + try d.create(table: Station.databaseTableName, ifNotExists: true) { t in + t.column("id", .text).primaryKey() + t.column("name", .text).notNull() + t.column("lat", .double).notNull() + t.column("lng", .double).notNull() + t.column("address", .text) + t.column("city", .text) + t.column("state", .text) + t.column("zip", .text) + t.column("prices_json", .text).notNull() + t.column("fetched_at", .integer).notNull() + } + try d.create(index: "idx_stations_lat_lng", on: Station.databaseTableName, columns: ["lat", "lng"], ifNotExists: true) + } + + if let all = try? db.read({ try Station.fetchAll($0) }) { + for s in all { byId[s.id] = s } + } + } + + // MARK: - Mutations func merge(_ stations: [Station]) { + try? db.write { d in + for s in stations { try s.save(d) } + } for s in stations { byId[s.id] = s } } - // Filtered + sorted by distance from a coordinate. + func clear() { + try? db.write { try Station.deleteAll($0) } + byId = [:] + } + + // MARK: - Queries + func stations(near coord: CLLocationCoordinate2D, radiusKm: Double) -> [Station] { let latDelta = radiusKm / 111.0 let lngDelta = radiusKm / (111.0 * cos(coord.latitude * .pi / 180)) @@ -28,7 +107,6 @@ final class StationStore: ObservableObject { } } - // Filtered to a map region. func stations(in region: MKCoordinateRegion) -> [Station] { let minLat = region.center.latitude - region.span.latitudeDelta / 2 let maxLat = region.center.latitude + region.span.latitudeDelta / 2 @@ -39,26 +117,4 @@ final class StationStore: ObservableObject { $0.lng >= minLng && $0.lng <= maxLng } } - - // Cache-only fetch for a map region. - func loadBbox(_ region: MKCoordinateRegion, api: APIClient) async { - let minLat = region.center.latitude - region.span.latitudeDelta / 2 - let maxLat = region.center.latitude + region.span.latitudeDelta / 2 - let minLng = region.center.longitude - region.span.longitudeDelta / 2 - let maxLng = region.center.longitude + region.span.longitudeDelta / 2 - if let results = try? await api.fetchBbox(minLat: minLat, minLng: minLng, maxLat: maxLat, maxLng: maxLng) { - merge(results) - } - } - - // Live fetch — may call GasBuddy if cell is stale. - func loadNearby(_ coord: CLLocationCoordinate2D, radiusKm: Double, api: APIClient, cacheOnly: Bool = false) async throws { - let results = try await api.fetchNearby( - lat: coord.latitude, - lng: coord.longitude, - radiusKm: radiusKm, - cacheOnly: cacheOnly - ) - merge(results) - } } diff --git a/gastrack/Views/MapStationsView.swift b/gastrack/Views/MapStationsView.swift index 3f2b7d3..fe9576c 100644 --- a/gastrack/Views/MapStationsView.swift +++ b/gastrack/Views/MapStationsView.swift @@ -159,14 +159,22 @@ struct MapStationsView: View { guard !isLoading else { return } isLoading = true do { - try await store.loadNearby(region.center, radiusKm: max(latSpan, lngSpan) * 111 / 2, api: api) + let radiusKm = max(latSpan, lngSpan) * 111 / 2 + let results = try await api.fetchNearby(lat: region.center.latitude, lng: region.center.longitude, radiusKm: radiusKm) + store.merge(results) stations = store.stations(in: region) } catch { self.error = error.localizedDescription } isLoading = false } else { - await store.loadBbox(region, api: api) + let minLat = region.center.latitude - latSpan / 2 + let maxLat = region.center.latitude + latSpan / 2 + let minLng = region.center.longitude - lngSpan / 2 + let maxLng = region.center.longitude + lngSpan / 2 + if let results = try? await api.fetchBbox(minLat: minLat, minLng: minLng, maxLat: maxLat, maxLng: maxLng) { + store.merge(results) + } stations = store.stations(in: region) } } diff --git a/gastrack/Views/NearbyView.swift b/gastrack/Views/NearbyView.swift index 93eca74..f395ad2 100644 --- a/gastrack/Views/NearbyView.swift +++ b/gastrack/Views/NearbyView.swift @@ -71,7 +71,9 @@ struct NearbyView: View { return } error = nil - try? await store.loadNearby(coord, radiusKm: radiusKm, api: api, cacheOnly: true) + if let results = try? await api.fetchNearby(lat: coord.latitude, lng: coord.longitude, radiusKm: radiusKm, cacheOnly: true) { + store.merge(results) + } } // Live — may call GasBuddy if cell is stale. @@ -80,7 +82,8 @@ struct NearbyView: View { error = nil isRefreshing = true do { - try await store.loadNearby(coord, radiusKm: radiusKm, api: api) + let results = try await api.fetchNearby(lat: coord.latitude, lng: coord.longitude, radiusKm: radiusKm) + store.merge(results) } catch { if stations.isEmpty { self.error = error.localizedDescription } } diff --git a/gastrack/Views/SettingsView.swift b/gastrack/Views/SettingsView.swift index de15234..fa1bd4d 100644 --- a/gastrack/Views/SettingsView.swift +++ b/gastrack/Views/SettingsView.swift @@ -2,10 +2,13 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject private var api: APIClient + @EnvironmentObject private var store: StationStore @State private var baseURL: String = "" @State private var deviceSecret: String = "" @State private var registrationStatus: String? @State private var isRegistering = false + @State private var health: HealthResponse? + @State private var isLoadingHealth = false var body: some View { NavigationStack { @@ -17,6 +20,25 @@ struct SettingsView: View { .keyboardType(.URL) } + Section { + LabeledContent("Local cache", value: "\(store.byId.count) stations") + Button("Clear local cache", role: .destructive) { + store.clear() + } + if let h = health { + LabeledContent("Server cache", value: "\(h.cachedStations) stations") + if let newest = h.newestFetch.flatMap({ ISO8601DateFormatter().date(from: $0) }) { + LabeledContent("Last fetch", value: newest.formatted(.relative(presentation: .named))) + } + } + Button(isLoadingHealth ? "Refreshing…" : "Refresh stats") { + Task { await loadHealth() } + } + .disabled(isLoadingHealth || baseURL.isEmpty) + } header: { + Text("Cache") + } + Section("Authentication") { SecureField("Device secret", text: $deviceSecret) .autocorrectionDisabled() @@ -44,6 +66,7 @@ struct SettingsView: View { .onAppear { baseURL = api.baseURL deviceSecret = KeychainService.load(forKey: "device_secret") ?? "" + Task { await loadHealth() } } .onChange(of: baseURL) { _, new in api.baseURL = new @@ -58,6 +81,13 @@ struct SettingsView: View { } } + private func loadHealth() async { + guard !baseURL.isEmpty else { return } + isLoadingHealth = true + health = try? await api.fetchHealth() + isLoadingHealth = false + } + private func register() async { isRegistering = true registrationStatus = nil diff --git a/gastrack/gastrack.xcodeproj/project.pbxproj b/gastrack/gastrack.xcodeproj/project.pbxproj index 0fa1d4a..8590136 100644 --- a/gastrack/gastrack.xcodeproj/project.pbxproj +++ b/gastrack/gastrack.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 0B5E3B5E2F75C0B6005F7F70 /* gas.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */; }; 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */; }; 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */; }; + 0B5E3B6A2F75F12D005F7F70 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5E3B692F75F12D005F7F70 /* GRDB */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -50,6 +51,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0B5E3B6A2F75F12D005F7F70 /* GRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -120,6 +122,7 @@ ); name = gastrack; packageProductDependencies = ( + 0B5E3B692F75F12D005F7F70 /* GRDB */, ); productName = gastrack; productReference = 0B5E3B432F75ACFB005F7F70 /* gastrack.app */; @@ -149,6 +152,9 @@ ); mainGroup = 0B5E3B2C2F75ACD9005F7F70; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 0B5E3B682F75F12D005F7F70 /* XCRemoteSwiftPackageReference "GRDB" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 0B5E3B2C2F75ACD9005F7F70; projectDirPath = ""; @@ -409,6 +415,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 0B5E3B682F75F12D005F7F70 /* XCRemoteSwiftPackageReference "GRDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0B5E3B692F75F12D005F7F70 /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = 0B5E3B682F75F12D005F7F70 /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0B5E3B2D2F75ACD9005F7F70 /* Project object */; } diff --git a/gastrack/gastrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/gastrack/gastrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..000b1e8 --- /dev/null +++ b/gastrack/gastrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "branch" : "master", + "revision" : "36e30a6f1ef10e4194f6af0cff90888526f0c115" + } + } + ], + "version" : 3 +}