From c4a82f86aae27cbdcf64982595dbc157f6ddab02 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 26 Mar 2026 15:33:42 -0400 Subject: [PATCH] feat: add inital swift app --- .gitignore | 11 + gastrack/.DS_Store | Bin 0 -> 6148 bytes .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ gastrack/Assets.xcassets/Contents.json | 6 + gastrack/ContentView.swift | 29 ++ gastrack/Models/Station.swift | 49 +++ gastrack/Services/APIClient.swift | 149 +++++++ gastrack/Services/EIAService.swift | 73 ++++ gastrack/Services/KeychainService.swift | 42 ++ gastrack/Services/LocationManager.swift | 39 ++ gastrack/Services/StationStore.swift | 64 +++ gastrack/Views/MapStationsView.swift | 135 ++++++ gastrack/Views/NearbyView.swift | 89 ++++ gastrack/Views/SettingsView.swift | 72 +++ gastrack/Views/StationDetailView.swift | 77 ++++ gastrack/Views/StationRow.swift | 64 +++ gastrack/gas.icon/Assets/emoji_u26fd.svg | 51 +++ gastrack/gas.icon/icon.json | 34 ++ gastrack/gastrack.xcodeproj/project.pbxproj | 410 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + gastrack/gastrackApp.swift | 25 ++ 22 files changed, 1472 insertions(+) create mode 100644 gastrack/.DS_Store create mode 100644 gastrack/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 gastrack/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 gastrack/Assets.xcassets/Contents.json create mode 100644 gastrack/ContentView.swift create mode 100644 gastrack/Models/Station.swift create mode 100644 gastrack/Services/APIClient.swift create mode 100644 gastrack/Services/EIAService.swift create mode 100644 gastrack/Services/KeychainService.swift create mode 100644 gastrack/Services/LocationManager.swift create mode 100644 gastrack/Services/StationStore.swift create mode 100644 gastrack/Views/MapStationsView.swift create mode 100644 gastrack/Views/NearbyView.swift create mode 100644 gastrack/Views/SettingsView.swift create mode 100644 gastrack/Views/StationDetailView.swift create mode 100644 gastrack/Views/StationRow.swift create mode 100644 gastrack/gas.icon/Assets/emoji_u26fd.svg create mode 100644 gastrack/gas.icon/icon.json create mode 100644 gastrack/gastrack.xcodeproj/project.pbxproj create mode 100644 gastrack/gastrack.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 gastrack/gastrackApp.swift diff --git a/.gitignore b/.gitignore index 467cd39..b01f88a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,14 @@ dist/ *.db-shm *.db-wal .env + +# Xcode +*.xcuserstate +xcuserdata/ +*.xccheckout +*.moved-aside +DerivedData/ +*.hmap +*.ipa +*.dSYM.zip +*.dSYM diff --git a/gastrack/.DS_Store b/gastrack/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5487129ee72000f83512d97590c6c0ad6e92acab GIT binary patch literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8zOq>i@0Z1N%F(jFwB0M1Tz-Hty6z8NH1}Ep|7C_}j zslgorptxgW@MXwnNMT52z=%HhI%MEr2xdrSC}JpM$Ye-nNJUReGLJ!aqN~Y8a!>5brhQQNV3?Jgk8l${b*S!g z0L30FLl{FQLn=c#x{D0ZWRcyn+wskHs9TWqBfA2-3RL?OKsJox(GVC7fzc44b_g&+ z4CLU3(4*vN2#kinXb8YV08~CGK-%^UpoS2L-vHr*m<)^z4B##RBLf2qOcNuxAHV>T z18D`(Agv%8q?LgY!~&ZE*2=&L)yfF&hJf^en}Q6C3}EdHj9{BV;$ZCzj9{A?7#Jbi z85p59GeUbPj1cV%j1cV%j9}Ygt{bICLtr!nU?IQ^VF-Zge^&+uT>XEDs!?(@1V%$( zScU*2i%YPJ6Sz{w?mtjn3#w0HqY 6 * 60 * 60 * 1000 + } +} + +struct PrefetchResponse: Codable { + struct Bbox: Codable { + let minLat: Double + let minLng: Double + let maxLat: Double + let maxLng: Double + } + let bbox: Bbox + let samples: Int + let stations: [Station] + let count: Int + let cachedAt: Int64 + + enum CodingKeys: String, CodingKey { + case bbox, samples, stations, count + case cachedAt = "cached_at" + } +} diff --git a/gastrack/Services/APIClient.swift b/gastrack/Services/APIClient.swift new file mode 100644 index 0000000..b408b89 --- /dev/null +++ b/gastrack/Services/APIClient.swift @@ -0,0 +1,149 @@ +import Combine +import Foundation + +enum APIError: LocalizedError { + case notConfigured + case httpError(Int, String) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .notConfigured: return "API not configured — no user key" + case .httpError(let code, let msg): return "HTTP \(code): \(msg)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + } + } +} + +final class APIClient: ObservableObject { + static let shared = APIClient() + + // Base URL of the gastrack server on Tailscale + var baseURL: String { + get { UserDefaults.standard.string(forKey: "gastrack_base_url") ?? "" } + set { UserDefaults.standard.set(newValue, forKey: "gastrack_base_url") } + } + + var userKey: String? { + KeychainService.load(forKey: "user_api_key") + } + + var deviceSecret: String? { + KeychainService.load(forKey: "device_secret") + } + + // Register a new user key using the device secret. Call on first launch. + func registerKey(email: String? = nil) async throws { + guard let secret = deviceSecret else { + throw APIError.notConfigured + } + let url = try endpoint("/keys/register") + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(["email": email]) + + let (data, response) = try await URLSession.shared.data(for: req) + try checkStatus(response, data) + + let body = try JSONDecoder().decode([String: String].self, from: data) + if let key = body["key"] { + KeychainService.save(key, forKey: "user_api_key") + } + } + + func fetchNearby(lat: Double, lng: Double, radiusKm: Double = 8, cacheOnly: Bool = false) async throws -> [Station] { + var comps = try urlComponents("/stations/nearby") + comps.queryItems = [ + .init(name: "lat", value: "\(lat)"), + .init(name: "lng", value: "\(lng)"), + .init(name: "radius_km", value: "\(radiusKm)"), + ] + if cacheOnly { comps.queryItems?.append(.init(name: "cache_only", value: "true")) } + return try await get(comps.url!) + } + + func fetchBbox(minLat: Double, minLng: Double, maxLat: Double, maxLng: Double) async throws -> [Station] { + var comps = try urlComponents("/stations/bbox") + comps.queryItems = [ + .init(name: "min_lat", value: "\(minLat)"), + .init(name: "min_lng", value: "\(minLng)"), + .init(name: "max_lat", value: "\(maxLat)"), + .init(name: "max_lng", value: "\(maxLng)"), + ] + return try await get(comps.url!) + } + + func fetchEIAAverages() async throws -> [EIAAverage] { + return try await get(try endpoint("/eia/averages")) + } + + func prefetchRoute(points: [[Double]], intervalKm: Double = 8) async throws -> PrefetchResponse { + let url = try endpoint("/prefetch/route") + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + try attachUserKey(&req) + + let body: [String: Any] = ["points": points, "interval_km": intervalKm] + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: req) + try checkStatus(response, data) + return try decode(data) + } + + // MARK: - Helpers + + private func get(_ url: URL) async throws -> T { + var req = URLRequest(url: url) + try attachUserKey(&req) + let (data, response) = try await URLSession.shared.data(for: req) + try checkStatus(response, data) + return try decode(data) + } + + private func attachUserKey(_ req: inout URLRequest) throws { + guard let key = userKey else { throw APIError.notConfigured } + req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + } + + private func endpoint(_ path: String) throws -> URL { + guard let url = URL(string: baseURL + path) else { + throw APIError.notConfigured + } + return url + } + + private func urlComponents(_ path: String) throws -> URLComponents { + guard let comps = URLComponents(string: baseURL + path) else { + throw APIError.notConfigured + } + return comps + } + + private func checkStatus(_ response: URLResponse, _ data: Data) throws { + guard let http = response as? HTTPURLResponse else { return } + guard (200..<300).contains(http.statusCode) else { + // Try to parse a JSON error message; fall back to generic status text + let msg: String + if let json = try? JSONDecoder().decode([String: String].self, from: data), + let error = json["error"] { + msg = error + } else { + msg = HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + } + throw APIError.httpError(http.statusCode, msg) + } + } + + private func decode(_ data: Data) throws -> T { + let decoder = JSONDecoder() + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } +} diff --git a/gastrack/Services/EIAService.swift b/gastrack/Services/EIAService.swift new file mode 100644 index 0000000..820e5c7 --- /dev/null +++ b/gastrack/Services/EIAService.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation + +struct EIAAverage: Codable { + let state: String + let regular: Double? + let period: String + let fetchedAt: Int64 +} + +@MainActor +final class EIAService: ObservableObject { + static let shared = EIAService() + + // state abbreviation -> price per gallon + private(set) var averages: [String: Double] = [:] + + private let cacheKey = "eia_averages_cache" + private let cacheDateKey = "eia_averages_fetched_at" + private let ttl: TimeInterval = 7 * 24 * 60 * 60 + + func load(api: APIClient) async { + // Use cached data if fresh + if let cached = loadCache(), !cached.isEmpty { + averages = cached + } + + // Refresh in background if stale + let fetchedAt = UserDefaults.standard.double(forKey: cacheDateKey) + if Date().timeIntervalSince1970 - fetchedAt > ttl { + await refresh(api: api) + } + } + + func deviation(for station: Station) -> Double? { + guard let state = station.state, + let avg = averages[state], + let priceStr = station.regularPrice?.formattedPrice, + let price = parsePrice(priceStr) else { return nil } + return price - avg + } + + private func refresh(api: APIClient) async { + do { + let data = try await api.fetchEIAAverages() + var map: [String: Double] = [:] + for entry in data { + if let price = entry.regular { + map[entry.state] = price + } + } + averages = map + saveCache(map) + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: cacheDateKey) + } catch { + print("EIA refresh failed: \(error)") + } + } + + private func parsePrice(_ s: String) -> Double? { + Double(s.trimmingCharacters(in: .init(charactersIn: "$"))) + } + + private func saveCache(_ map: [String: Double]) { + guard let data = try? JSONEncoder().encode(map) else { return } + UserDefaults.standard.set(data, forKey: cacheKey) + } + + private func loadCache() -> [String: Double]? { + guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return nil } + return try? JSONDecoder().decode([String: Double].self, from: data) + } +} diff --git a/gastrack/Services/KeychainService.swift b/gastrack/Services/KeychainService.swift new file mode 100644 index 0000000..1cde377 --- /dev/null +++ b/gastrack/Services/KeychainService.swift @@ -0,0 +1,42 @@ +import Foundation +import Security + +enum KeychainService { + private static let service = "com.gastrack.app" + + static func save(_ value: String, forKey key: String) { + let data = Data(value.utf8) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + ] + SecItemDelete(query as CFDictionary) + var attrs = query + attrs[kSecValueData] = data + SecItemAdd(attrs as CFDictionary, nil) + } + + static func load(forKey key: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(forKey key: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/gastrack/Services/LocationManager.swift b/gastrack/Services/LocationManager.swift new file mode 100644 index 0000000..ffeeb45 --- /dev/null +++ b/gastrack/Services/LocationManager.swift @@ -0,0 +1,39 @@ +import CoreLocation +import Combine + +@MainActor +final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { + static let shared = LocationManager() + + @Published var location: CLLocation? + @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined + + private let manager = CLLocationManager() + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func requestPermission() { + manager.requestWhenInUseAuthorization() + } + + func startUpdating() { + manager.startUpdatingLocation() + } + + func stopUpdating() { + manager.stopUpdatingLocation() + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + Task { @MainActor in self.location = loc } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in self.authorizationStatus = manager.authorizationStatus } + } +} diff --git a/gastrack/Services/StationStore.swift b/gastrack/Services/StationStore.swift new file mode 100644 index 0000000..5e7e967 --- /dev/null +++ b/gastrack/Services/StationStore.swift @@ -0,0 +1,64 @@ +import Combine +import CoreLocation +import MapKit + +@MainActor +final class StationStore: ObservableObject { + static let shared = StationStore() + + @Published private(set) var byId: [String: Station] = [:] + + func merge(_ stations: [Station]) { + for s in stations { byId[s.id] = s } + } + + // Filtered + sorted by distance from a coordinate. + func stations(near coord: CLLocationCoordinate2D, radiusKm: Double) -> [Station] { + let latDelta = radiusKm / 111.0 + let lngDelta = radiusKm / (111.0 * cos(coord.latitude * .pi / 180)) + let userLoc = CLLocation(latitude: coord.latitude, longitude: coord.longitude) + return byId.values + .filter { + abs($0.lat - coord.latitude) <= latDelta && + abs($0.lng - coord.longitude) <= lngDelta + } + .sorted { + CLLocation(latitude: $0.lat, longitude: $0.lng).distance(from: userLoc) < + CLLocation(latitude: $1.lat, longitude: $1.lng).distance(from: userLoc) + } + } + + // 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 + let minLng = region.center.longitude - region.span.longitudeDelta / 2 + let maxLng = region.center.longitude + region.span.longitudeDelta / 2 + return byId.values.filter { + $0.lat >= minLat && $0.lat <= maxLat && + $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 new file mode 100644 index 0000000..8310091 --- /dev/null +++ b/gastrack/Views/MapStationsView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import MapKit + +struct MapStationsView: View { + @EnvironmentObject private var api: APIClient + @EnvironmentObject private var store: StationStore + @StateObject private var location = LocationManager.shared + + @State private var position: MapCameraPosition = .userLocation(fallback: .automatic) + @State private var visibleRegion: MKCoordinateRegion? + @State private var selectedStation: Station? + @State private var isLoading = false + @State private var error: String? + @State private var lastAutoLoad: Date = .distantPast + + private var visibleStations: [Station] { + guard let region = visibleRegion else { return [] } + return store.stations(in: region) + } + + var body: some View { + NavigationStack { + Map(position: $position, selection: $selectedStation) { + ForEach(visibleStations) { station in + if let price = station.regularPrice?.formattedPrice { + Marker(price, systemImage: "fuelpump.fill", coordinate: CLLocationCoordinate2D( + latitude: station.lat, + longitude: station.lng + )) + .tint(station.isStale ? .orange : .green) + .tag(station) + } else { + Marker(station.name, systemImage: "fuelpump", coordinate: CLLocationCoordinate2D( + latitude: station.lat, + longitude: station.lng + )) + .tint(.gray) + .tag(station) + } + } + UserAnnotation() + } + .onMapCameraChange(frequency: .onEnd) { context in + visibleRegion = context.region + Task { await autoLoad() } + } + .mapControls { + MapUserLocationButton() + MapCompass() + MapScaleView() + } + .navigationTitle("Map") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { Task { await loadVisible(live: true) } } label: { + if isLoading { + ProgressView() + } else { + Label("Refresh area", systemImage: "arrow.clockwise") + } + } + .disabled(isLoading) + } + } + .overlay(alignment: .bottom) { + if let error { + Text(error) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial) + .clipShape(Capsule()) + .padding(.bottom, 8) + } + } + .sheet(item: $selectedStation) { station in + NavigationStack { + StationDetailView(station: station) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { selectedStation = nil } + } + } + } + .presentationDetents([.medium]) + } + } + .onAppear { + location.startUpdating() + } + } + + private func autoLoad() async { + guard Date().timeIntervalSince(lastAutoLoad) > 60 else { return } + lastAutoLoad = Date() + // Show cache instantly, then fetch live in background. + await loadVisible(live: false) + Task { await loadVisible(live: true) } + } + + private func loadVisible(live: Bool) async { + let region = visibleRegion ?? { + guard let loc = location.location else { return nil } + return MKCoordinateRegion(center: loc.coordinate, latitudinalMeters: 16_000, longitudinalMeters: 16_000) + }() + guard let region else { return } + + let latSpan = region.span.latitudeDelta + let lngSpan = region.span.longitudeDelta + guard latSpan * lngSpan <= 0.5 else { + error = "Zoom in to load stations" + return + } + error = nil + + if live { + guard !isLoading else { return } + isLoading = true + do { + try await store.loadNearby(region.center, radiusKm: max(latSpan, lngSpan) * 111 / 2, api: api) + } catch { + self.error = error.localizedDescription + } + isLoading = false + } else { + await store.loadBbox(region, api: api) + } + } +} + +extension Station: Hashable { + static func == (lhs: Station, rhs: Station) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/gastrack/Views/NearbyView.swift b/gastrack/Views/NearbyView.swift new file mode 100644 index 0000000..93eca74 --- /dev/null +++ b/gastrack/Views/NearbyView.swift @@ -0,0 +1,89 @@ +import SwiftUI +import CoreLocation + +struct NearbyView: View { + @StateObject private var location = LocationManager.shared + @EnvironmentObject private var api: APIClient + @EnvironmentObject private var store: StationStore + + @State private var isRefreshing = false + @State private var error: String? + + private let radiusKm = 16.0 + + private var stations: [Station] { + guard let coord = location.location?.coordinate else { return [] } + return store.stations(near: coord, radiusKm: radiusKm) + } + + var body: some View { + NavigationStack { + Group { + if stations.isEmpty && isRefreshing { + ProgressView("Fetching stations…") + } else if let error, stations.isEmpty { + ContentUnavailableView(error, systemImage: "antenna.radiowaves.left.and.right.slash") + } else if stations.isEmpty { + ContentUnavailableView("No stations found", systemImage: "fuelpump.slash") + } else { + List(stations) { station in + NavigationLink(destination: StationDetailView(station: station)) { + StationRow(station: station) + } + } + .refreshable { await loadLive() } + } + } + .navigationTitle("Nearby") + .toolbar { + ToolbarItem(placement: .primaryAction) { + if isRefreshing { + ProgressView() + } else { + Button { Task { await loadLive() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + } + } + } + .task { await loadCached() } + .onChange(of: location.location) { _, _ in + guard stations.isEmpty else { return } + Task { await loadCached() } + } + .onAppear { + if location.authorizationStatus == .notDetermined { + location.requestPermission() + } + location.startUpdating() + } + } + + // Instant, works offline — merges into shared store. + private func loadCached() async { + guard let coord = location.location?.coordinate else { + if location.authorizationStatus == .denied { + error = "Location access denied" + } else { + error = "Waiting for location…" + } + return + } + error = nil + try? await store.loadNearby(coord, radiusKm: radiusKm, api: api, cacheOnly: true) + } + + // Live — may call GasBuddy if cell is stale. + private func loadLive() async { + guard let coord = location.location?.coordinate else { return } + error = nil + isRefreshing = true + do { + try await store.loadNearby(coord, radiusKm: radiusKm, api: api) + } catch { + if stations.isEmpty { self.error = error.localizedDescription } + } + isRefreshing = false + } +} diff --git a/gastrack/Views/SettingsView.swift b/gastrack/Views/SettingsView.swift new file mode 100644 index 0000000..de15234 --- /dev/null +++ b/gastrack/Views/SettingsView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var api: APIClient + @State private var baseURL: String = "" + @State private var deviceSecret: String = "" + @State private var registrationStatus: String? + @State private var isRegistering = false + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Base URL (e.g. http://100.x.x.x:7878)", text: $baseURL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + + Section("Authentication") { + SecureField("Device secret", text: $deviceSecret) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button(isRegistering ? "Registering…" : "Register API key") { + Task { await register() } + } + .disabled(isRegistering || deviceSecret.isEmpty || baseURL.isEmpty) + + if let status = registrationStatus { + Text(status) + .font(.caption) + .foregroundStyle(status.hasPrefix("✓") ? .green : .red) + } + + if KeychainService.load(forKey: "user_api_key") != nil { + Label("API key stored", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + .font(.caption) + } + } + } + .navigationTitle("Settings") + .onAppear { + baseURL = api.baseURL + deviceSecret = KeychainService.load(forKey: "device_secret") ?? "" + } + .onChange(of: baseURL) { _, new in + api.baseURL = new + } + .onChange(of: deviceSecret) { _, new in + if new.isEmpty { + KeychainService.delete(forKey: "device_secret") + } else { + KeychainService.save(new, forKey: "device_secret") + } + } + } + } + + private func register() async { + isRegistering = true + registrationStatus = nil + do { + try await api.registerKey() + registrationStatus = "✓ Key registered successfully" + } catch { + registrationStatus = "✗ \(error.localizedDescription)" + } + isRegistering = false + } +} diff --git a/gastrack/Views/StationDetailView.swift b/gastrack/Views/StationDetailView.swift new file mode 100644 index 0000000..587b119 --- /dev/null +++ b/gastrack/Views/StationDetailView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import MapKit + +struct StationDetailView: View { + let station: Station + @EnvironmentObject private var eia: EIAService + + var body: some View { + List { + Section { + Button { + openInMaps() + } label: { + VStack(alignment: .leading, spacing: 3) { + if let address = station.address { + Text(address) + } + if let city = station.city, let state = station.state { + Text("\(city), \(state) \(station.zip ?? "")".trimmingCharacters(in: .whitespaces)) + .foregroundStyle(.secondary) + } + } + .font(.subheadline) + } + .foregroundStyle(.primary) + } header: { + Label("Location", systemImage: "mappin") + } + + Section { + ForEach(station.prices) { price in + HStack { + Text(price.nickname) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + if let fp = price.formattedPrice { + Text(fp) + } else { + Text("Not reported").foregroundStyle(.secondary) + } + if price.nickname == "Regular", let dev = eia.deviation(for: station) { + Text(deviationLabel(dev)) + .font(.caption2.bold()) + .foregroundStyle(dev < 0 ? .green : .red) + } + } + } + } + } header: { + Text("Prices") + } footer: { + let date = Date(timeIntervalSince1970: Double(station.fetchedAt) / 1000) + HStack(spacing: 6) { + Text("Fetched \(date.formatted(.relative(presentation: .named)))") + if station.isStale { + Label("May be outdated", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + } + } + .navigationTitle(station.name) + .navigationBarTitleDisplayMode(.inline) + } + + private func openInMaps() { + let placemark = MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)) + let mapItem = MKMapItem(placemark: placemark) + mapItem.name = station.name + mapItem.openInMaps() + } + + private func deviationLabel(_ dev: Double) -> String { + let prefix = dev < 0 ? "-" : "+" + return "\(prefix)$\(String(format: "%.2f", abs(dev))) vs state avg" + } +} diff --git a/gastrack/Views/StationRow.swift b/gastrack/Views/StationRow.swift new file mode 100644 index 0000000..079c344 --- /dev/null +++ b/gastrack/Views/StationRow.swift @@ -0,0 +1,64 @@ +import SwiftUI +import CoreLocation + +struct StationRow: View { + let station: Station + @EnvironmentObject private var eia: EIAService + @StateObject private var location = LocationManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(station.name) + .font(.headline) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + if let price = station.regularPrice?.formattedPrice { + Text(price) + .font(.headline) + } else { + Text("—") + .foregroundStyle(.secondary) + } + if let dev = eia.deviation(for: station) { + Text(deviationLabel(dev)) + .font(.caption2.bold()) + .foregroundStyle(dev < 0 ? .green : .red) + } + } + } + + HStack(spacing: 6) { + if let city = station.city, let state = station.state { + Text("\(city), \(state)") + } + if let dist = distanceMiles { + Text("·") + Text(dist) + } + } + .font(.caption) + .foregroundStyle(.secondary) + + if station.isStale { + Label("Stale data", systemImage: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + .padding(.vertical, 2) + } + + private var distanceMiles: String? { + guard let loc = location.location else { return nil } + let stationLoc = CLLocation(latitude: station.lat, longitude: station.lng) + let meters = loc.distance(from: stationLoc) + let miles = meters / 1609.34 + return miles < 10 ? String(format: "%.1f mi", miles) : String(format: "%.0f mi", miles) + } + + private func deviationLabel(_ dev: Double) -> String { + let prefix = dev < 0 ? "-" : "+" + return "\(prefix)$\(String(format: "%.2f", abs(dev))) vs state avg" + } +} diff --git a/gastrack/gas.icon/Assets/emoji_u26fd.svg b/gastrack/gas.icon/Assets/emoji_u26fd.svg new file mode 100644 index 0000000..962d90d --- /dev/null +++ b/gastrack/gas.icon/Assets/emoji_u26fd.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gastrack/gas.icon/icon.json b/gastrack/gas.icon/icon.json new file mode 100644 index 0000000..965a540 --- /dev/null +++ b/gastrack/gas.icon/icon.json @@ -0,0 +1,34 @@ +{ + "fill" : "system-dark", + "groups" : [ + { + "layers" : [ + { + "image-name" : "emoji_u26fd.svg", + "name" : "emoji_u26fd", + "position" : { + "scale" : 6, + "translation-in-points" : [ + -0.8993379128923493, + 1.751700357341747 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/gastrack/gastrack.xcodeproj/project.pbxproj b/gastrack/gastrack.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a0892cb --- /dev/null +++ b/gastrack/gastrack.xcodeproj/project.pbxproj @@ -0,0 +1,410 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0B5E3B392F75ACD9005F7F70 /* gastrackApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B382F75ACD9005F7F70 /* gastrackApp.swift */; }; + 0B5E3B3B2F75ACD9005F7F70 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B3A2F75ACD9005F7F70 /* ContentView.swift */; }; + 0B5E3B3D2F75ACDA005F7F70 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0B5E3B3C2F75ACDA005F7F70 /* Assets.xcassets */; }; + 0B5E3B502F75B002005F7F70 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B482F75B002005F7F70 /* LocationManager.swift */; }; + 0B5E3B512F75B002005F7F70 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B462F75B002005F7F70 /* APIClient.swift */; }; + 0B5E3B522F75B002005F7F70 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */; }; + 0B5E3B532F75B002005F7F70 /* StationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B4E2F75B002005F7F70 /* StationRow.swift */; }; + 0B5E3B542F75B002005F7F70 /* StationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B4D2F75B002005F7F70 /* StationDetailView.swift */; }; + 0B5E3B552F75B002005F7F70 /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B442F75B002005F7F70 /* Station.swift */; }; + 0B5E3B562F75B002005F7F70 /* NearbyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B4B2F75B002005F7F70 /* NearbyView.swift */; }; + 0B5E3B572F75B002005F7F70 /* MapStationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B4A2F75B002005F7F70 /* MapStationsView.swift */; }; + 0B5E3B582F75B002005F7F70 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B472F75B002005F7F70 /* KeychainService.swift */; }; + 0B5E3B5C2F75B3E9005F7F70 /* EIAService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */; }; + 0B5E3B5E2F75C0B6005F7F70 /* gas.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */; }; + 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0B5E3B382F75ACD9005F7F70 /* gastrackApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gastrackApp.swift; sourceTree = ""; }; + 0B5E3B3A2F75ACD9005F7F70 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 0B5E3B3C2F75ACDA005F7F70 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 0B5E3B432F75ACFB005F7F70 /* gastrack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gastrack.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0B5E3B442F75B002005F7F70 /* Station.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; + 0B5E3B462F75B002005F7F70 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 0B5E3B472F75B002005F7F70 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + 0B5E3B482F75B002005F7F70 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; + 0B5E3B4A2F75B002005F7F70 /* MapStationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapStationsView.swift; sourceTree = ""; }; + 0B5E3B4B2F75B002005F7F70 /* NearbyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyView.swift; sourceTree = ""; }; + 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 0B5E3B4D2F75B002005F7F70 /* StationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationDetailView.swift; sourceTree = ""; }; + 0B5E3B4E2F75B002005F7F70 /* StationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationRow.swift; sourceTree = ""; }; + 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EIAService.swift; sourceTree = ""; }; + 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = gas.icon; sourceTree = ""; }; + 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationStore.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0B5E3B322F75ACD9005F7F70 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0B5E3B2C2F75ACD9005F7F70 = { + isa = PBXGroup; + children = ( + 0B5E3B452F75B002005F7F70 /* Models */, + 0B5E3B492F75B002005F7F70 /* Services */, + 0B5E3B4F2F75B002005F7F70 /* Views */, + 0B5E3B3C2F75ACDA005F7F70 /* Assets.xcassets */, + 0B5E3B3A2F75ACD9005F7F70 /* ContentView.swift */, + 0B5E3B382F75ACD9005F7F70 /* gastrackApp.swift */, + 0B5E3B432F75ACFB005F7F70 /* gastrack.app */, + 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */, + ); + sourceTree = ""; + }; + 0B5E3B452F75B002005F7F70 /* Models */ = { + isa = PBXGroup; + children = ( + 0B5E3B442F75B002005F7F70 /* Station.swift */, + ); + path = Models; + sourceTree = ""; + }; + 0B5E3B492F75B002005F7F70 /* Services */ = { + isa = PBXGroup; + children = ( + 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */, + 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */, + 0B5E3B462F75B002005F7F70 /* APIClient.swift */, + 0B5E3B472F75B002005F7F70 /* KeychainService.swift */, + 0B5E3B482F75B002005F7F70 /* LocationManager.swift */, + ); + path = Services; + sourceTree = ""; + }; + 0B5E3B4F2F75B002005F7F70 /* Views */ = { + isa = PBXGroup; + children = ( + 0B5E3B4A2F75B002005F7F70 /* MapStationsView.swift */, + 0B5E3B4B2F75B002005F7F70 /* NearbyView.swift */, + 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */, + 0B5E3B4D2F75B002005F7F70 /* StationDetailView.swift */, + 0B5E3B4E2F75B002005F7F70 /* StationRow.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0B5E3B342F75ACD9005F7F70 /* gastrack */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0B5E3B402F75ACDA005F7F70 /* Build configuration list for PBXNativeTarget "gastrack" */; + buildPhases = ( + 0B5E3B312F75ACD9005F7F70 /* Sources */, + 0B5E3B322F75ACD9005F7F70 /* Frameworks */, + 0B5E3B332F75ACD9005F7F70 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = gastrack; + packageProductDependencies = ( + ); + productName = gastrack; + productReference = 0B5E3B432F75ACFB005F7F70 /* gastrack.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0B5E3B2D2F75ACD9005F7F70 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 0B5E3B342F75ACD9005F7F70 = { + CreatedOnToolsVersion = 26.4; + }; + }; + }; + buildConfigurationList = 0B5E3B302F75ACD9005F7F70 /* Build configuration list for PBXProject "gastrack" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0B5E3B2C2F75ACD9005F7F70; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 0B5E3B2C2F75ACD9005F7F70; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0B5E3B342F75ACD9005F7F70 /* gastrack */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0B5E3B332F75ACD9005F7F70 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0B5E3B5E2F75C0B6005F7F70 /* gas.icon in Resources */, + 0B5E3B3D2F75ACDA005F7F70 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0B5E3B312F75ACD9005F7F70 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0B5E3B3B2F75ACD9005F7F70 /* ContentView.swift in Sources */, + 0B5E3B502F75B002005F7F70 /* LocationManager.swift in Sources */, + 0B5E3B512F75B002005F7F70 /* APIClient.swift in Sources */, + 0B5E3B5C2F75B3E9005F7F70 /* EIAService.swift in Sources */, + 0B5E3B522F75B002005F7F70 /* SettingsView.swift in Sources */, + 0B5E3B532F75B002005F7F70 /* StationRow.swift in Sources */, + 0B5E3B542F75B002005F7F70 /* StationDetailView.swift in Sources */, + 0B5E3B552F75B002005F7F70 /* Station.swift in Sources */, + 0B5E3B562F75B002005F7F70 /* NearbyView.swift in Sources */, + 0B5E3B572F75B002005F7F70 /* MapStationsView.swift in Sources */, + 0B5E3B582F75B002005F7F70 /* KeychainService.swift in Sources */, + 0B5E3B392F75ACD9005F7F70 /* gastrackApp.swift in Sources */, + 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0B5E3B3E2F75ACDA005F7F70 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = M67B42LX8D; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0B5E3B3F2F75ACDA005F7F70 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = M67B42LX8D; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0B5E3B412F75ACDA005F7F70 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = gas; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M67B42LX8D; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Gastrack; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.navigation"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Used to find gas stations near you"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.gastrack; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0B5E3B422F75ACDA005F7F70 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = gas; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M67B42LX8D; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Gastrack; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.navigation"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Used to find gas stations near you"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.gastrack; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0B5E3B302F75ACD9005F7F70 /* Build configuration list for PBXProject "gastrack" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B5E3B3E2F75ACDA005F7F70 /* Debug */, + 0B5E3B3F2F75ACDA005F7F70 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0B5E3B402F75ACDA005F7F70 /* Build configuration list for PBXNativeTarget "gastrack" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B5E3B412F75ACDA005F7F70 /* Debug */, + 0B5E3B422F75ACDA005F7F70 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0B5E3B2D2F75ACD9005F7F70 /* Project object */; +} diff --git a/gastrack/gastrack.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/gastrack/gastrack.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/gastrack/gastrack.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/gastrack/gastrackApp.swift b/gastrack/gastrackApp.swift new file mode 100644 index 0000000..7507a77 --- /dev/null +++ b/gastrack/gastrackApp.swift @@ -0,0 +1,25 @@ +// +// gastrackApp.swift +// gastrack +// +// Created by Kieran Klukas on 3/26/26. +// + +import SwiftUI + +@main +struct gastrackApp: App { + @StateObject private var api = APIClient.shared + @StateObject private var eia = EIAService.shared + @StateObject private var store = StationStore.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(api) + .environmentObject(eia) + .environmentObject(store) + .task { await eia.load(api: api) } + } + } +} -- 2.51.2