From 9f699b33bb887bd1954f0ca442e045655fe5da40 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 27 Mar 2026 20:17:46 -0700 Subject: [PATCH] feat: overhaul the routes feature --- overpass/AppIntents/RouteIntents.swift | 105 ++++ overpass/Models/SavedRoute.swift | 30 ++ overpass/Models/Station.swift | 4 +- .../Services/BackgroundRefreshService.swift | 55 +- overpass/Services/RouteStore.swift | 39 ++ overpass/Views/PrefetchView.swift | 367 ++++--------- overpass/Views/RouteEditorView.swift | 486 ++++++++++++++++++ overpass/overpass.xcodeproj/project.pbxproj | 24 + overpass/overpassApp.swift | 2 + 9 files changed, 816 insertions(+), 296 deletions(-) create mode 100644 overpass/AppIntents/RouteIntents.swift create mode 100644 overpass/Models/SavedRoute.swift create mode 100644 overpass/Services/RouteStore.swift create mode 100644 overpass/Views/RouteEditorView.swift diff --git a/overpass/AppIntents/RouteIntents.swift b/overpass/AppIntents/RouteIntents.swift new file mode 100644 index 0000000..bb5bf31 --- /dev/null +++ b/overpass/AppIntents/RouteIntents.swift @@ -0,0 +1,105 @@ +import AppIntents +import CoreLocation +import MapKit + +// MARK: - Entity + +struct SavedRouteEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Route" + static var defaultQuery = SavedRouteQuery() + + var id: UUID + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +// MARK: - Query + +struct SavedRouteQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [SavedRouteEntity] { + await MainActor.run { + RouteStore.shared.routes + .filter { identifiers.contains($0.id) } + .map { SavedRouteEntity(id: $0.id, name: $0.name) } + } + } + + func suggestedEntities() async throws -> [SavedRouteEntity] { + await MainActor.run { + RouteStore.shared.routes.map { SavedRouteEntity(id: $0.id, name: $0.name) } + } + } +} + +// MARK: - Intent + +struct PrefetchRouteIntent: AppIntent { + static var title: LocalizedStringResource = "Cache Route" + static var description = IntentDescription("Cache gas station data along a saved route.") + + @Parameter(title: "Route") var route: SavedRouteEntity + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let saved = await MainActor.run(body: { RouteStore.shared.routes.first(where: { $0.id == route.id }) }) else { + throw $route.needsValueError("Which route would you like to cache?") + } + + let to = saved.toCoordinate + let req = MKDirections.Request() + req.destination = MKMapItem(location: CLLocation(latitude: to.latitude, longitude: to.longitude), address: nil) + if let from = saved.fromCoordinate { + req.source = MKMapItem(location: CLLocation(latitude: from.latitude, longitude: from.longitude), address: nil) + } else { + req.source = .forCurrentLocation() + } + req.transportType = .automobile + + guard let mkRoute = try? await MKDirections(request: req).calculate().routes.first else { + return .result(dialog: "Couldn't calculate the route for \(saved.name).") + } + + let points = extractPoints(from: mkRoute.polyline) + let response = try await APIClient.shared.prefetchRoute(points: points) + await MainActor.run { + StationStore.shared.merge(response.stations) + var updated = saved + updated.lastCachedAt = Date() + updated.cachedCount = response.count + RouteStore.shared.save(updated) + } + await MainActor.run { BackgroundRefreshService.markRouteRefreshed() } + + return .result(dialog: "Cached \(response.count) stations along \(saved.name).") + } + + private func extractPoints(from polyline: MKPolyline, max: Int = 400) -> [[Double]] { + let count = polyline.pointCount + let step = Swift.max(1, Int(ceil(Double(count) / Double(max)))) + var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) + polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) + var result: [[Double]] = [] + var i = 0 + while i < count { result.append([coords[i].latitude, coords[i].longitude]); i += step } + return result + } +} + +// MARK: - Shortcuts + +struct OverpassShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: PrefetchRouteIntent(), + phrases: [ + "Cache \(\.$route) in \(.applicationName)", + "Prefetch \(\.$route) in \(.applicationName)", + "Update \(\.$route) stations in \(.applicationName)", + ], + shortTitle: "Cache Route", + systemImageName: "arrow.down.circle.fill" + ) + } +} diff --git a/overpass/Models/SavedRoute.swift b/overpass/Models/SavedRoute.swift new file mode 100644 index 0000000..e09762e --- /dev/null +++ b/overpass/Models/SavedRoute.swift @@ -0,0 +1,30 @@ +import CoreLocation +import Foundation + +struct SavedRoute: Codable, Identifiable { + var id: UUID = UUID() + var name: String + var fromName: String? = nil + var fromLat: Double? = nil + var fromLng: Double? = nil + var toName: String + var toLat: Double + var toLng: Double + var distanceKm: Double? = nil + var durationSeconds: Double? = nil + var bboxMinLat: Double? = nil + var bboxMinLng: Double? = nil + var bboxMaxLat: Double? = nil + var bboxMaxLng: Double? = nil + var lastCachedAt: Date? = nil + var cachedCount: Int? = nil + + nonisolated var fromCoordinate: CLLocationCoordinate2D? { + guard let lat = fromLat, let lng = fromLng else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + nonisolated var toCoordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: toLat, longitude: toLng) + } +} diff --git a/overpass/Models/Station.swift b/overpass/Models/Station.swift index 146fca0..f124cb1 100644 --- a/overpass/Models/Station.swift +++ b/overpass/Models/Station.swift @@ -7,7 +7,7 @@ struct Price: Codable, Identifiable { let postedTime: String? // "$3.29" → 3.29 - var numericPrice: Double? { + nonisolated var numericPrice: Double? { guard let fp = formattedPrice, fp.hasPrefix("$") else { return nil } return Double(fp.dropFirst()) } @@ -25,7 +25,7 @@ struct Station: Codable, Identifiable, Sendable { let prices: [Price] let fetchedAt: Int64 - var regularPrice: Price? { + nonisolated var regularPrice: Price? { prices.first { $0.nickname == "Regular" } } diff --git a/overpass/Services/BackgroundRefreshService.swift b/overpass/Services/BackgroundRefreshService.swift index 52513d7..6924a47 100644 --- a/overpass/Services/BackgroundRefreshService.swift +++ b/overpass/Services/BackgroundRefreshService.swift @@ -1,17 +1,18 @@ import CoreLocation import Foundation +import MapKit /// Lightweight refresh logic — called when the app becomes active. /// No background modes or BGTaskScheduler needed. enum BackgroundRefreshService { - private static let nearbyKey = "last_nearby_refresh" - private static let routeKey = "last_route_refresh" - private static let nearbyTTL: TimeInterval = 30 * 60 // 30 min + private static let nearbyKey: String = "last_nearby_refresh" + private static let routeKey: String = "last_route_refresh" + private static let nearbyTTL: TimeInterval = 30 * 60 // 30 min private static let routeTTL: TimeInterval = 6 * 60 * 60 // 6 h static func refreshIfNeeded() async { async let _ = refreshNearbyIfNeeded() - async let _ = refreshRouteIfNeeded() + async let _ = refreshRoutesIfNeeded() } // MARK: - Nearby @@ -31,25 +32,53 @@ enum BackgroundRefreshService { } } - // MARK: - Route + // MARK: - Routes - static func refreshRouteIfNeeded() async { + static func refreshRoutesIfNeeded() async { let last = UserDefaults.standard.double(forKey: routeKey) guard Date().timeIntervalSince1970 - last > routeTTL else { return } - guard let json = UserDefaults.standard.string(forKey: "route_points"), - let data = json.data(using: .utf8), - let points = try? JSONDecoder().decode([[Double]].self, from: data), - !points.isEmpty else { return } + let routes = await MainActor.run { + RouteStore.shared.routes.filter { $0.lastCachedAt != nil } + } + guard !routes.isEmpty else { return } + + for saved in routes { + let req = MKDirections.Request() + let to = saved.toCoordinate + req.destination = MKMapItem(location: CLLocation(latitude: to.latitude, longitude: to.longitude), address: nil) + if let from = saved.fromCoordinate { + req.source = MKMapItem(location: CLLocation(latitude: from.latitude, longitude: from.longitude), address: nil) + } else { + req.source = .forCurrentLocation() + } + req.transportType = .automobile - if let response = try? await APIClient.shared.prefetchRoute(points: points) { - await MainActor.run { StationStore.shared.merge(response.stations) } - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: routeKey) + guard let mkRoute = try? await MKDirections(request: req).calculate().routes.first else { continue } + let points = extractPoints(from: mkRoute.polyline) + if let response = try? await APIClient.shared.prefetchRoute(points: points) { + await MainActor.run { StationStore.shared.merge(response.stations) } + } } + + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: routeKey) } /// Call after a successful manual prefetch to reset the route TTL. static func markRouteRefreshed() { UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: routeKey) } + + // MARK: - Helpers + + private static func extractPoints(from polyline: MKPolyline, max: Int = 400) -> [[Double]] { + let count = polyline.pointCount + let step = Swift.max(1, Int(ceil(Double(count) / Double(max)))) + var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) + polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) + var result: [[Double]] = [] + var i = 0 + while i < count { result.append([coords[i].latitude, coords[i].longitude]); i += step } + return result + } } diff --git a/overpass/Services/RouteStore.swift b/overpass/Services/RouteStore.swift new file mode 100644 index 0000000..e7fb353 --- /dev/null +++ b/overpass/Services/RouteStore.swift @@ -0,0 +1,39 @@ +import Combine +import Foundation +import SwiftUI + +final class RouteStore: ObservableObject { + static let shared = RouteStore() + + @Published var routes: [SavedRoute] = [] + + private let key = "sh.dunkirk.overpass.routes" + + private init() { load() } + + func save(_ route: SavedRoute) { + if let i = routes.firstIndex(where: { $0.id == route.id }) { + routes[i] = route + } else { + routes.append(route) + } + persist() + } + + func delete(at offsets: IndexSet) { + routes.remove(atOffsets: offsets) + persist() + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([SavedRoute].self, from: data) + else { return } + routes = decoded + } + + private func persist() { + guard let data = try? JSONEncoder().encode(routes) else { return } + UserDefaults.standard.set(data, forKey: key) + } +} diff --git a/overpass/Views/PrefetchView.swift b/overpass/Views/PrefetchView.swift index 11395b2..6f8b81c 100644 --- a/overpass/Views/PrefetchView.swift +++ b/overpass/Views/PrefetchView.swift @@ -1,312 +1,117 @@ -import Combine -import SwiftUI -import MapKit import CoreLocation - -// MARK: - Completer - -private final class SearchCompleter: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { - @Published var results: [MKLocalSearchCompletion] = [] - private let inner = MKLocalSearchCompleter() - - override init() { - super.init() - inner.delegate = self - inner.resultTypes = [.address, .pointOfInterest] - } - - func query(_ text: String, near region: MKCoordinateRegion?) { - if let r = region { inner.region = r } - inner.queryFragment = text - } - - func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { - results = Array(completer.results.prefix(6)) - } - - func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { - results = [] - } -} - -// MARK: - View +import MapKit +import SwiftUI struct PrefetchView: View { - @EnvironmentObject private var api: APIClient - @EnvironmentObject private var store: StationStore + @EnvironmentObject private var routeStore: RouteStore @StateObject private var location = LocationManager.shared - @StateObject private var completer = SearchCompleter() - - enum ActiveField { case from, to } - - @AppStorage("route_from_text") private var fromText: String = "" - @AppStorage("route_to_text") private var toText: String = "" - @AppStorage("route_to_lat") private var toLatSaved: Double = 0 - @AppStorage("route_to_lng") private var toLngSaved: Double = 0 - @AppStorage("route_from_lat") private var fromLatSaved: Double = 0 - @AppStorage("route_from_lng") private var fromLngSaved: Double = 0 - - @FocusState private var focused: ActiveField? - @State private var fromItem: MKMapItem? // nil = current location - @State private var toItem: MKMapItem? - @State private var route: MKRoute? - @State private var routePosition: MapCameraPosition = .automatic - @State private var isPrefetching = false - @State private var prefetchResult: (stations: Int, samples: Int)? - @State private var errorMsg: String? + @State private var editingRoute: SavedRoute? + @State private var isAdding = false + @State private var currentLocationName: String? var body: some View { NavigationStack { - List { - // ── Route inputs ── - Section { - locationRow( - systemImage: "circle.fill", - tint: .blue, - text: $fromText, - placeholder: "Current location", - field: .from + Group { + if routeStore.routes.isEmpty { + ContentUnavailableView( + "No Routes", + systemImage: "road.lanes", + description: Text("Save a route to pre-cache gas stations along your drive.") ) - locationRow( - systemImage: "mappin.circle.fill", - tint: .red, - text: $toText, - placeholder: "Destination", - field: .to - ) - } - - // ── Autocomplete suggestions ── - if focused != nil && !completer.results.isEmpty { - Section { - ForEach(completer.results, id: \.self) { c in - Button { pick(c) } label: { - VStack(alignment: .leading, spacing: 2) { - Text(c.title).foregroundStyle(.primary) - if !c.subtitle.isEmpty { - Text(c.subtitle).font(.caption).foregroundStyle(.secondary) - } - } - } - } - } - } - - // ── Route preview ── - if let route { - Section { - Map(position: $routePosition) { - MapPolyline(route.polyline) - .stroke(.blue, lineWidth: 3) - } - .frame(height: 180) - .listRowInsets(.init()) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - HStack(spacing: 24) { - Label(String(format: "%.0f km", route.distance / 1000), systemImage: "arrow.left.and.right") - Label(formatDuration(route.expectedTravelTime), systemImage: "clock") - } - .font(.callout) - .foregroundStyle(.secondary) - } - } - - // ── Prefetch button ── - Section { - Button { - Task { await prefetch() } - } label: { - HStack { - Spacer() - if isPrefetching { - ProgressView().tint(.white) - Text("Caching stations…").foregroundStyle(.white) - } else { - Label("Prefetch Route", systemImage: "arrow.down.circle.fill") - .foregroundStyle(.white) + } else { + List { + ForEach(routeStore.routes) { route in + Button { + editingRoute = route + } label: { + RouteRowView(route: route, currentLocationName: currentLocationName) } - Spacer() + .tint(.primary) } - .padding(.vertical, 4) + .onDelete { routeStore.delete(at: $0) } } - .listRowBackground(toItem != nil && !isPrefetching ? Color.accentColor : Color.secondary) - .disabled(toItem == nil || isPrefetching) } - - // ── Error / result ── - if let errorMsg { - Section { - Label(errorMsg, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - } - } - - if let r = prefetchResult { - Section("Last Prefetch") { - LabeledContent("Stations cached", value: "\(r.stations)") - LabeledContent("New cells fetched", value: "\(r.samples)") + } + .navigationTitle("Routes") + .task(id: location.location) { + guard let loc = location.location, + let request = MKReverseGeocodingRequest(location: loc), + let item = try? await request.mapItems.first else { return } + currentLocationName = item.name + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { isAdding = true } label: { + Image(systemName: "plus") } } } - .navigationTitle("Prefetch Route") - .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $isAdding) { + RouteEditorView(route: nil) + } + .sheet(item: $editingRoute) { route in + RouteEditorView(route: route) + } } - .task { await restoreRoute() } } +} - private func restoreRoute() async { - guard route == nil else { return } - if toLatSaved != 0 && toLngSaved != 0 { - let toCoord = CLLocationCoordinate2D(latitude: toLatSaved, longitude: toLngSaved) - toItem = MKMapItem(location: CLLocation(latitude: toCoord.latitude, longitude: toCoord.longitude), address: nil) - } - if fromLatSaved != 0 && fromLngSaved != 0 { - let fromCoord = CLLocationCoordinate2D(latitude: fromLatSaved, longitude: fromLngSaved) - fromItem = MKMapItem(location: CLLocation(latitude: fromCoord.latitude, longitude: fromCoord.longitude), address: nil) - } - if toItem != nil { await fetchRoute() } - } +// MARK: - Row - // MARK: - Row +private struct RouteRowView: View { + let route: SavedRoute + let currentLocationName: String? - @ViewBuilder - private func locationRow( - systemImage: String, - tint: Color, - text: Binding, - placeholder: String, - field: ActiveField - ) -> some View { - HStack(spacing: 10) { - Image(systemName: systemImage) - .foregroundStyle(tint) - .frame(width: 24) + private var fromLabel: String? { route.fromName ?? currentLocationName } - TextField(placeholder, text: text) - .focused($focused, equals: field) - .autocorrectionDisabled() - .textInputAutocapitalization(.words) - .submitLabel(field == .from ? .next : .search) - .onChange(of: text.wrappedValue) { _, new in - if focused == field { - completer.query(new, near: location.location.map { - MKCoordinateRegion(center: $0.coordinate, latitudinalMeters: 500_000, longitudinalMeters: 500_000) - }) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(route.name) + .font(.headline) + .foregroundStyle(.primary) + + HStack(spacing: 4) { + if let from = fromLabel { + Text(from) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Text(route.toName) + } + .font(.subheadline) + .foregroundStyle(.secondary) + + if route.distanceKm != nil || route.durationSeconds != nil || route.cachedCount != nil { + HStack(spacing: 10) { + if let km = route.distanceKm { + HStack(spacing: 3) { + Image(systemName: "arrow.left.and.right").imageScale(.small) + Text(String(format: "%.0f km", km)) + } } - if new.isEmpty { - if field == .from { fromItem = nil } - if field == .to { toItem = nil; route = nil } + if let secs = route.durationSeconds { + HStack(spacing: 3) { + Image(systemName: "clock").imageScale(.small) + Text(formatDuration(secs)) + } + } + if let count = route.cachedCount { + HStack(spacing: 3) { + Image(systemName: "fuelpump").imageScale(.small) + Text("\(count) stations") + } } } - .onSubmit { if field == .from { focused = .to } } - - if !text.wrappedValue.isEmpty { - Button { - text.wrappedValue = "" - if field == .from { fromItem = nil } - if field == .to { toItem = nil; route = nil } - completer.results = [] - } label: { - Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) - } - .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) } - } - } - // MARK: - Actions - - private func pick(_ completion: MKLocalSearchCompletion) { - let field = focused - focused = nil - completer.results = [] - Task { - let req = MKLocalSearch.Request(completion: completion) - guard let item = try? await MKLocalSearch(request: req).start().mapItems.first else { return } - let coord = item.location.coordinate - if field == .from { - fromText = completion.title - fromItem = item - fromLatSaved = coord.latitude - fromLngSaved = coord.longitude - } else { - toText = completion.title - toItem = item - toLatSaved = coord.latitude - toLngSaved = coord.longitude - } - await fetchRoute() - } - } - - private func fetchRoute() async { - guard let dest = toItem else { return } - let src = fromItem ?? .forCurrentLocation() - let req = MKDirections.Request() - req.source = src - req.destination = dest - req.transportType = .automobile - guard let r = try? await MKDirections(request: req).calculate().routes.first else { return } - route = r - let rect = r.polyline.boundingMapRect - routePosition = .rect(rect.insetBy(dx: -rect.size.width * 0.15, dy: -rect.size.height * 0.15)) - } - - private func prefetch() async { - guard let dest = toItem else { return } - errorMsg = nil - isPrefetching = true - - let currentRoute: MKRoute - if let r = route { - currentRoute = r - } else { - let req = MKDirections.Request() - req.source = fromItem ?? .forCurrentLocation() - req.destination = dest - req.transportType = .automobile - guard let r = try? await MKDirections(request: req).calculate().routes.first else { - errorMsg = "Could not calculate route" - isPrefetching = false - return - } - currentRoute = r - route = r - } - - let points = extractPoints(from: currentRoute.polyline, maxPoints: 400) - do { - let response = try await api.prefetchRoute(points: points) - store.merge(response.stations) - prefetchResult = (stations: response.count, samples: response.samples) - if let json = String(data: (try? JSONEncoder().encode(points)) ?? Data(), encoding: .utf8) { - UserDefaults.standard.set(json, forKey: "route_points") - } - BackgroundRefreshService.markRouteRefreshed() - } catch { - errorMsg = error.localizedDescription - } - isPrefetching = false - } - - // MARK: - Helpers - - private func extractPoints(from polyline: MKPolyline, maxPoints: Int) -> [[Double]] { - let count = polyline.pointCount - let step = Swift.max(1, Int(ceil(Double(count) / Double(maxPoints)))) - var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) - polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) - var result: [[Double]] = [] - var i = 0 - while i < count { - result.append([coords[i].latitude, coords[i].longitude]) - i += step - } - return result +} + .padding(.vertical, 2) } - private func formatDuration(_ seconds: TimeInterval) -> String { + private func formatDuration(_ seconds: Double) -> String { let h = Int(seconds) / 3600 let m = (Int(seconds) % 3600) / 60 return h > 0 ? "\(h)h \(m)m" : "\(m) min" diff --git a/overpass/Views/RouteEditorView.swift b/overpass/Views/RouteEditorView.swift new file mode 100644 index 0000000..d9f005a --- /dev/null +++ b/overpass/Views/RouteEditorView.swift @@ -0,0 +1,486 @@ +import Combine +import CoreLocation +import MapKit +import SwiftUI + +// MARK: - Search completer + +private final class SearchCompleter: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { + @Published var results: [MKLocalSearchCompletion] = [] + private var _inner: MKLocalSearchCompleter? + + // Lazy — MKLocalSearchCompleter is only created when the user starts typing, + // avoiding a main-thread stall on sheet presentation. + private var inner: MKLocalSearchCompleter { + if let c = _inner { return c } + let c = MKLocalSearchCompleter() + c.delegate = self + c.resultTypes = [.address, .pointOfInterest] + _inner = c + return c + } + + func query(_ text: String, near region: MKCoordinateRegion?) { + if let r = region { inner.region = r } + inner.queryFragment = text + } + + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + results = Array(completer.results.prefix(6)) + } + + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + results = [] + } +} + +// MARK: - View + +struct RouteEditorView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var api: APIClient + @EnvironmentObject private var store: StationStore + @EnvironmentObject private var routeStore: RouteStore + @StateObject private var location = LocationManager.shared + @StateObject private var completer = SearchCompleter() + + let initialRoute: SavedRoute? + + init(route: SavedRoute?) { + self.initialRoute = route + } + + enum ActiveField { case from, to } + + @State private var routeId: UUID = UUID() + @State private var name: String = "" + @State private var fromText: String = "" + @State private var toText: String = "" + @State private var fromItem: MKMapItem? + @State private var toItem: MKMapItem? + @State private var route: MKRoute? + @State private var routePosition: MapCameraPosition = .automatic + @State private var isPrefetching = false + @State private var prefetchResult: (stations: Int, samples: Int)? + @State private var routeStations: [Station] = [] + @State private var errorMsg: String? + @FocusState private var focused: ActiveField? + + // Cache state — updated by prefetch() and restored from initialRoute + @State private var sessionCachedAt: Date? + @State private var sessionCachedCount: Int? + @State private var sessionBboxMinLat: Double? + @State private var sessionBboxMinLng: Double? + @State private var sessionBboxMaxLat: Double? + @State private var sessionBboxMaxLng: Double? + + private var isEditing: Bool { initialRoute != nil } + private var canSave: Bool { !name.isEmpty && toItem != nil } + private var canPrefetch: Bool { toItem != nil && !isPrefetching } + + var body: some View { + NavigationStack { + List { + nameSection + locationsSection + + if focused != nil && !completer.results.isEmpty { + suggestionsSection + } + + if let route { + routePreviewSection(route) + } + + prefetchSection + + if let errorMsg { + Section { + Label(errorMsg, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + if let r = prefetchResult { + Section { + LabeledContent("Stations cached", value: "\(r.stations)") + LabeledContent("Grid cells fetched", value: "\(r.samples)") + } + } + + if !routeStations.isEmpty { + Section { + ForEach(routeStations) { station in + NavigationLink { + StationDetailView(station: station) + } label: { + StationRow(station: station) + } + } + } header: { + Text("\(routeStations.count) Stations Along Route") + } + } + } + .navigationTitle(isEditing ? "Edit Route" : "New Route") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!canSave) + } + } + .onAppear { + restore() + if initialRoute == nil { Task { await prefillCurrentLocation() } } + } + } + } + + // MARK: - Sections + + private var nameSection: some View { + Section("Route Name") { + TextField("e.g. Morning Commute", text: $name) + .autocorrectionDisabled() + .submitLabel(.next) + .onSubmit { focused = .from } + } + } + + private var locationsSection: some View { + Section { + locationRow( + systemImage: "location.fill", tint: .blue, + text: $fromText, placeholder: "Current location", field: .from + ) + locationRow( + systemImage: "mappin.circle.fill", tint: .red, + text: $toText, placeholder: "Destination", field: .to + ) + } + } + + private var suggestionsSection: some View { + Section { + ForEach(completer.results, id: \.self) { c in + Button { pick(c) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(c.title).foregroundStyle(.primary) + if !c.subtitle.isEmpty { + Text(c.subtitle).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + } + + private func routePreviewSection(_ route: MKRoute) -> some View { + Section { + Map(position: $routePosition) { + MapPolyline(route.polyline) + .stroke(.blue, lineWidth: 3) + } + .frame(height: 180) + .listRowInsets(.init()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + HStack(spacing: 24) { + Label(String(format: "%.0f km", route.distance / 1000), systemImage: "arrow.left.and.right") + Label(formatDuration(route.expectedTravelTime), systemImage: "clock") + } + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private var prefetchSection: some View { + Section { + Button { + Task { await prefetch() } + } label: { + Group { + if isPrefetching { + HStack(spacing: 8) { + ProgressView() + Text("Caching stations…") + } + } else { + Text("Cache Stations Now") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!canPrefetch) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + // MARK: - Row builder + + @ViewBuilder + private func locationRow( + systemImage: String, + tint: Color, + text: Binding, + placeholder: String, + field: ActiveField + ) -> some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: 24) + + TextField(placeholder, text: text) + .focused($focused, equals: field) + .autocorrectionDisabled() + .textInputAutocapitalization(.words) + .submitLabel(field == .from ? .next : .search) + .onChange(of: text.wrappedValue) { _, new in + if focused == field { + completer.query(new, near: location.location.map { + MKCoordinateRegion(center: $0.coordinate, + latitudinalMeters: 500_000, + longitudinalMeters: 500_000) + }) + } + if new.isEmpty { + if field == .from { fromItem = nil } + if field == .to { toItem = nil; route = nil } + } + } + .onSubmit { if field == .from { focused = .to } } + + if !text.wrappedValue.isEmpty { + Button { + text.wrappedValue = "" + if field == .from { fromItem = nil } + if field == .to { toItem = nil; route = nil } + completer.results = [] + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Actions + + private func restore() { + guard let r = initialRoute else { return } + name = r.name + fromText = r.fromName ?? "" + toText = r.toName + if let coord = r.fromCoordinate { + fromItem = MKMapItem(location: CLLocation(latitude: coord.latitude, longitude: coord.longitude), address: nil) + } + let to = r.toCoordinate + toItem = MKMapItem(location: CLLocation(latitude: to.latitude, longitude: to.longitude), address: nil) + routeId = r.id + sessionCachedAt = r.lastCachedAt + sessionCachedCount = r.cachedCount + sessionBboxMinLat = r.bboxMinLat + sessionBboxMinLng = r.bboxMinLng + sessionBboxMaxLat = r.bboxMaxLat + sessionBboxMaxLng = r.bboxMaxLng + Task { + await fetchRoute() + loadStationsFromStore(r) + } + } + + private func prefillCurrentLocation() async { + guard let loc = location.location, + let request = MKReverseGeocodingRequest(location: loc), + let item = try? await request.mapItems.first else { return } + fromItem = item + fromText = item.name ?? "" + } + + private func pick(_ completion: MKLocalSearchCompletion) { + let field = focused + focused = nil + completer.results = [] + Task { + let req = MKLocalSearch.Request(completion: completion) + guard let item = try? await MKLocalSearch(request: req).start().mapItems.first else { return } + if field == .from { + fromText = completion.title + fromItem = item + } else { + toText = completion.title + toItem = item + if name.isEmpty { + name = fromText.isEmpty ? completion.title : "\(fromText) → \(completion.title)" + } + } + await fetchRoute() + } + } + + private func fetchRoute() async { + guard let dest = toItem else { return } + let req = MKDirections.Request() + req.source = fromItem ?? .forCurrentLocation() + req.destination = dest + req.transportType = .automobile + guard let r = try? await MKDirections(request: req).calculate().routes.first else { return } + route = r + let rect = r.polyline.boundingMapRect + routePosition = .rect(rect.insetBy(dx: -rect.size.width * 0.15, dy: -rect.size.height * 0.15)) + } + + private func prefetch() async { + guard let dest = toItem else { return } + errorMsg = nil + isPrefetching = true + defer { isPrefetching = false } + + let currentRoute: MKRoute + if let r = route { + currentRoute = r + } else { + let req = MKDirections.Request() + req.source = fromItem ?? .forCurrentLocation() + req.destination = dest + req.transportType = .automobile + guard let r = try? await MKDirections(request: req).calculate().routes.first else { + errorMsg = "Could not calculate route" + return + } + currentRoute = r + route = r + } + + do { + let points = extractPoints(from: currentRoute.polyline) + let response = try await api.prefetchRoute(points: points) + store.merge(response.stations) + prefetchResult = (stations: response.count, samples: response.samples) + + // Update session cache state so buildRoute() picks it up + sessionCachedAt = Date() + sessionCachedCount = response.count + sessionBboxMinLat = response.bbox.minLat + sessionBboxMinLng = response.bbox.minLng + sessionBboxMaxLat = response.bbox.maxLat + sessionBboxMaxLng = response.bbox.maxLng + routeStore.save(buildRoute()) + + let stations = response.stations + Task { + routeStations = await Task.detached(priority: .userInitiated) { + Self.filterAndSort(stations, polyline: nil) + }.value + } + + BackgroundRefreshService.markRouteRefreshed() + } catch { + errorMsg = error.localizedDescription + } + } + + private func save() { + routeStore.save(buildRoute()) + dismiss() + } + + private func buildRoute() -> SavedRoute { + var r = initialRoute ?? SavedRoute( + id: routeId, + name: name, + toName: toText, + toLat: toItem?.location.coordinate.latitude ?? 0, + toLng: toItem?.location.coordinate.longitude ?? 0 + ) + r.name = name + r.fromName = fromText.isEmpty ? nil : fromText + r.fromLat = fromItem?.location.coordinate.latitude + r.fromLng = fromItem?.location.coordinate.longitude + r.toName = toText + r.toLat = toItem?.location.coordinate.latitude ?? r.toLat + r.toLng = toItem?.location.coordinate.longitude ?? r.toLng + if let mkRoute = route { + r.distanceKm = mkRoute.distance / 1000 + r.durationSeconds = mkRoute.expectedTravelTime + } + r.lastCachedAt = sessionCachedAt + r.cachedCount = sessionCachedCount + r.bboxMinLat = sessionBboxMinLat + r.bboxMinLng = sessionBboxMinLng + r.bboxMaxLat = sessionBboxMaxLat + r.bboxMaxLng = sessionBboxMaxLng + return r + } + + private func loadStationsFromStore(_ r: SavedRoute) { + guard let minLat = r.bboxMinLat, let minLng = r.bboxMinLng, + let maxLat = r.bboxMaxLat, let maxLng = r.bboxMaxLng else { return } + let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, + longitude: (minLng + maxLng) / 2) + let region = MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: maxLat - minLat, longitudeDelta: maxLng - minLng) + ) + let candidates = store.stations(in: region) + let polyline = route?.polyline + Task { + let sorted = await Task.detached(priority: .userInitiated) { + Self.filterAndSort(candidates, polyline: polyline) + }.value + routeStations = sorted + } + } + + private nonisolated static func filterAndSort(_ stations: [Station], polyline: MKPolyline?) -> [Station] { + let filtered: [Station] + if let polyline { + let count = polyline.pointCount + let step = Swift.max(1, count / 200) + var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) + polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) + filtered = stations.filter { station in + let loc = CLLocation(latitude: station.lat, longitude: station.lng) + var i = 0 + while i < count { + if CLLocation(latitude: coords[i].latitude, longitude: coords[i].longitude) + .distance(from: loc) <= 2000 { return true } + i += step + } + return false + } + } else { + filtered = stations + } + return filtered.sorted { + ($0.regularPrice?.numericPrice ?? .infinity) < ($1.regularPrice?.numericPrice ?? .infinity) + } + } + + // MARK: - Helpers + + private func extractPoints(from polyline: MKPolyline, max: Int = 400) -> [[Double]] { + let count = polyline.pointCount + let step = Swift.max(1, Int(ceil(Double(count) / Double(max)))) + var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) + polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) + var result: [[Double]] = [] + var i = 0 + while i < count { result.append([coords[i].latitude, coords[i].longitude]); i += step } + return result + } + + private func formatDuration(_ seconds: TimeInterval) -> String { + let h = Int(seconds) / 3600 + let m = (Int(seconds) % 3600) / 60 + return h > 0 ? "\(h)h \(m)m" : "\(m) min" + } +} diff --git a/overpass/overpass.xcodeproj/project.pbxproj b/overpass/overpass.xcodeproj/project.pbxproj index a0f6c63..396caeb 100644 --- a/overpass/overpass.xcodeproj/project.pbxproj +++ b/overpass/overpass.xcodeproj/project.pbxproj @@ -25,6 +25,10 @@ 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */; }; 0B5E3B6A2F75F12D005F7F70 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5E3B692F75F12D005F7F70 /* GRDB */; }; 0B5E3B6C2F76D765005F7F70 /* BackgroundRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */; }; + 0B7A9C030000000000000000 /* RouteIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C020000000000000000 /* RouteIntents.swift */; }; + 0B7A9C050000000000000000 /* SavedRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C040000000000000000 /* SavedRoute.swift */; }; + 0B7A9C070000000000000000 /* RouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C060000000000000000 /* RouteStore.swift */; }; + 0B7A9C090000000000000000 /* RouteEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C080000000000000000 /* RouteEditorView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -46,6 +50,10 @@ 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationStore.swift; sourceTree = ""; }; 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchView.swift; sourceTree = ""; }; 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshService.swift; sourceTree = ""; }; + 0B7A9C020000000000000000 /* RouteIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteIntents.swift; sourceTree = ""; }; + 0B7A9C040000000000000000 /* SavedRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRoute.swift; sourceTree = ""; }; + 0B7A9C060000000000000000 /* RouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStore.swift; sourceTree = ""; }; + 0B7A9C080000000000000000 /* RouteEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteEditorView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -66,6 +74,7 @@ 0B5E3B452F75B002005F7F70 /* Models */, 0B5E3B492F75B002005F7F70 /* Services */, 0B5E3B4F2F75B002005F7F70 /* Views */, + 0B7A9C010000000000000000 /* AppIntents */, 0B5E3B3C2F75ACDA005F7F70 /* Assets.xcassets */, 0B5E3B3A2F75ACD9005F7F70 /* ContentView.swift */, 0B5E3B382F75ACD9005F7F70 /* overpassApp.swift */, @@ -74,10 +83,19 @@ ); sourceTree = ""; }; + 0B7A9C010000000000000000 /* AppIntents */ = { + isa = PBXGroup; + children = ( + 0B7A9C020000000000000000 /* RouteIntents.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; 0B5E3B452F75B002005F7F70 /* Models */ = { isa = PBXGroup; children = ( 0B5E3B442F75B002005F7F70 /* Station.swift */, + 0B7A9C040000000000000000 /* SavedRoute.swift */, ); path = Models; sourceTree = ""; @@ -91,6 +109,7 @@ 0B5E3B462F75B002005F7F70 /* APIClient.swift */, 0B5E3B472F75B002005F7F70 /* KeychainService.swift */, 0B5E3B482F75B002005F7F70 /* LocationManager.swift */, + 0B7A9C060000000000000000 /* RouteStore.swift */, ); path = Services; sourceTree = ""; @@ -99,6 +118,7 @@ isa = PBXGroup; children = ( 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */, + 0B7A9C080000000000000000 /* RouteEditorView.swift */, 0B5E3B4A2F75B002005F7F70 /* MapStationsView.swift */, 0B5E3B4B2F75B002005F7F70 /* NearbyView.swift */, 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */, @@ -200,6 +220,10 @@ 0B5E3B392F75ACD9005F7F70 /* overpassApp.swift in Sources */, 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */, 0B5E3B6C2F76D765005F7F70 /* BackgroundRefreshService.swift in Sources */, + 0B7A9C030000000000000000 /* RouteIntents.swift in Sources */, + 0B7A9C050000000000000000 /* SavedRoute.swift in Sources */, + 0B7A9C070000000000000000 /* RouteStore.swift in Sources */, + 0B7A9C090000000000000000 /* RouteEditorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/overpass/overpassApp.swift b/overpass/overpassApp.swift index 2187ad1..b8351e3 100644 --- a/overpass/overpassApp.swift +++ b/overpass/overpassApp.swift @@ -51,6 +51,7 @@ struct overpassApp: App { @StateObject private var api = APIClient.shared @StateObject private var eia = EIAService.shared @StateObject private var store = StationStore.shared + @StateObject private var routeStore = RouteStore.shared @Environment(\.scenePhase) private var scenePhase var body: some Scene { @@ -59,6 +60,7 @@ struct overpassApp: App { .environmentObject(api) .environmentObject(eia) .environmentObject(store) + .environmentObject(routeStore) .task { await eia.load(api: api) } } .onChange(of: scenePhase) { _, phase in -- 2.51.2