diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ad4a7f540..99b1e4331 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -35,6 +35,10 @@ 8C84D33E2B5AEE0200192C0A /* NearbyTransitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C84D33D2B5AEE0200192C0A /* NearbyTransitView.swift */; }; 8CC1BB402B59D1F6005386FE /* LocationDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */; }; 8CD1F8CD2B7164C100F419D4 /* PredictionsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CD1F8CC2B7164C100F419D4 /* PredictionsFetcher.swift */; }; + 8CE0140E2BBDB7C300918FAE /* RoutePillSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */; }; + 8CE014102BBDB8DC00918FAE /* StopDetailsRoutesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE0140F2BBDB8DC00918FAE /* StopDetailsRoutesView.swift */; }; + 8CE014122BBDB96900918FAE /* StopDetailsRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE014112BBDB96900918FAE /* StopDetailsRouteView.swift */; }; + 8CE014142BBDBE5200918FAE /* BackendProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE014132BBDBE5200918FAE /* BackendProvider.swift */; }; 8CE0141B2BBF059B00918FAE /* SheetNavigationStackEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE0141A2BBF059A00918FAE /* SheetNavigationStackEntry.swift */; }; 8CEA10232BA0F3C6001C6EB9 /* ScheduleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CEA10222BA0F3C6001C6EB9 /* ScheduleFetcher.swift */; }; 8CEA10252BA4B179001C6EB9 /* AlertsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CEA10242BA4B179001C6EB9 /* AlertsFetcher.swift */; }; @@ -159,6 +163,10 @@ 8C84D33D2B5AEE0200192C0A /* NearbyTransitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyTransitView.swift; sourceTree = ""; }; 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataManager.swift; sourceTree = ""; }; 8CD1F8CC2B7164C100F419D4 /* PredictionsFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionsFetcher.swift; sourceTree = ""; }; + 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePillSection.swift; sourceTree = ""; }; + 8CE0140F2BBDB8DC00918FAE /* StopDetailsRoutesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsRoutesView.swift; sourceTree = ""; }; + 8CE014112BBDB96900918FAE /* StopDetailsRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsRouteView.swift; sourceTree = ""; }; + 8CE014132BBDBE5200918FAE /* BackendProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendProvider.swift; sourceTree = ""; }; 8CE0141A2BBF059A00918FAE /* SheetNavigationStackEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetNavigationStackEntry.swift; sourceTree = ""; }; 8CEA10222BA0F3C6001C6EB9 /* ScheduleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleFetcher.swift; sourceTree = ""; }; 8CEA10242BA4B179001C6EB9 /* AlertsFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsFetcher.swift; sourceTree = ""; }; @@ -399,6 +407,8 @@ isa = PBXGroup; children = ( 8C5054572BB5EB6C00C6A51C /* StopDetailsPage.swift */, + 8CE0140F2BBDB8DC00918FAE /* StopDetailsRoutesView.swift */, + 8CE014112BBDB96900918FAE /* StopDetailsRouteView.swift */, ); path = StopDetails; sourceTree = ""; @@ -456,6 +466,7 @@ children = ( 9A4E8E582B7EC4B90066B936 /* RoutePill.swift */, 9AB44A102B8FC43E00E8FFB3 /* IconCard.swift */, + 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */, ); path = ComponentViews; sourceTree = ""; @@ -493,6 +504,7 @@ 8CEA10222BA0F3C6001C6EB9 /* ScheduleFetcher.swift */, 8CEA10242BA4B179001C6EB9 /* AlertsFetcher.swift */, 9A5830572BA4A1A30039876E /* ViewportProvider.swift */, + 8CE014132BBDBE5200918FAE /* BackendProvider.swift */, ); path = Fetchers; sourceTree = ""; @@ -854,6 +866,7 @@ 6EE7457E2B965ADE0052227E /* Socket.swift in Sources */, 9A5B27582BB22BF9009A6FC6 /* MapLayerManager.swift in Sources */, 9A03F3662BA9E68500DA40DC /* Debouncer.swift in Sources */, + 8CE014102BBDB8DC00918FAE /* StopDetailsRoutesView.swift in Sources */, 9A8B34AD2B88E5090018412C /* RailRouteShapeFetcher.swift in Sources */, 9AB446B02BBDDCAF00D8C920 /* StopIcons.swift in Sources */, 9A2005CB2B97B68700F562E1 /* UpcomingTripView.swift in Sources */, @@ -862,6 +875,7 @@ 9A9E05F62B6D6EF70086B437 /* NearbyFetcher.swift in Sources */, 9A2005C92B97B65900F562E1 /* NearbyStopRoutePatternView.swift in Sources */, 9A4E8E592B7EC4B90066B936 /* RoutePill.swift in Sources */, + 8CE014142BBDBE5200918FAE /* BackendProvider.swift in Sources */, 9A5B27562BB221C1009A6FC6 /* RouteLayerGenerator.swift in Sources */, 8CE0141B2BBF059B00918FAE /* SheetNavigationStackEntry.swift in Sources */, 8C5054582BB5EB6C00C6A51C /* StopDetailsPage.swift in Sources */, @@ -873,6 +887,7 @@ 9A887D572B683103006F5B80 /* SearchResultView.swift in Sources */, 8CEA10252BA4B179001C6EB9 /* AlertsFetcher.swift in Sources */, 9A5B275A2BB22D91009A6FC6 /* StopLayerGenerator.swift in Sources */, + 8CE014122BBDB96900918FAE /* StopDetailsRouteView.swift in Sources */, 6E35D4D02B72C7B700A2BF95 /* HomeMapView.swift in Sources */, 8CC1BB402B59D1F6005386FE /* LocationDataManager.swift in Sources */, ED3581662BB4706F005DDC34 /* PartialSheetModifier.swift in Sources */, @@ -887,6 +902,7 @@ 8C84D33E2B5AEE0200192C0A /* NearbyTransitView.swift in Sources */, 9AC10BDA2B80067400EA4605 /* ColorHexExtension.swift in Sources */, 9A37F3052BACCC40001714FE /* DoubleRoundedExtension.swift in Sources */, + 8CE0140E2BBDB7C300918FAE /* RoutePillSection.swift in Sources */, 9A2005C52B97B5EA00F562E1 /* NearbyRouteView.swift in Sources */, 9A5830562BA3A2CE0039876E /* ViewportExtension.swift in Sources */, 9A5830582BA4A1A30039876E /* ViewportProvider.swift in Sources */, diff --git a/iosApp/iosApp/ComponentViews/RoutePillSection.swift b/iosApp/iosApp/ComponentViews/RoutePillSection.swift new file mode 100644 index 000000000..00ae0714b --- /dev/null +++ b/iosApp/iosApp/ComponentViews/RoutePillSection.swift @@ -0,0 +1,20 @@ +// +// RoutePillSection.swift +// iosApp +// +// Created by Horn, Melody on 2024-04-03. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +import shared +import SwiftUI + +struct RoutePillSection: View { + let route: Route + let content: () -> Content + + var body: some View { + Section(content: content, header: { RoutePill(route: route).padding(.leading, -20) }) + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 96275c5da..8ff444104 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -10,6 +10,7 @@ struct ContentView: View { @StateObject var searchObserver = TextFieldObserver() @EnvironmentObject var locationDataManager: LocationDataManager @EnvironmentObject var alertsFetcher: AlertsFetcher + @EnvironmentObject var backendProvider: BackendProvider @EnvironmentObject var globalFetcher: GlobalFetcher @EnvironmentObject var nearbyFetcher: NearbyFetcher @EnvironmentObject var predictionsFetcher: PredictionsFetcher @@ -64,7 +65,12 @@ struct ContentView: View { .navigationDestination(for: SheetNavigationStackEntry.self) { entry in switch entry { case let .stopDetails(stop, route): - StopDetailsPage(stop: stop, route: route) + StopDetailsPage( + backend: backendProvider.backend, + socket: socketProvider.socket, + globalFetcher: globalFetcher, + stop: stop, route: route + ) } } } diff --git a/iosApp/iosApp/Fetchers/BackendProvider.swift b/iosApp/iosApp/Fetchers/BackendProvider.swift new file mode 100644 index 000000000..c346cb671 --- /dev/null +++ b/iosApp/iosApp/Fetchers/BackendProvider.swift @@ -0,0 +1,18 @@ +// +// BackendProvider.swift +// iosApp +// +// Created by Horn, Melody on 2024-04-03. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +import shared + +class BackendProvider: ObservableObject { + @Published var backend: any BackendProtocol + + init(backend: any BackendProtocol) { + self.backend = backend + } +} diff --git a/iosApp/iosApp/IOSApp.swift b/iosApp/iosApp/IOSApp.swift index 5084d3c23..139d50436 100644 --- a/iosApp/iosApp/IOSApp.swift +++ b/iosApp/iosApp/IOSApp.swift @@ -11,6 +11,7 @@ struct IOSApp: App { @StateObject var locationDataManager: LocationDataManager @StateObject var alertsFetcher: AlertsFetcher + @StateObject var backendProvider: BackendProvider @StateObject var globalFetcher: GlobalFetcher @StateObject var nearbyFetcher: NearbyFetcher @StateObject var predictionsFetcher: PredictionsFetcher @@ -44,6 +45,7 @@ struct IOSApp: App { _locationDataManager = StateObject(wrappedValue: LocationDataManager(distanceFilter: 100)) _alertsFetcher = StateObject(wrappedValue: AlertsFetcher(socket: socket)) + _backendProvider = StateObject(wrappedValue: BackendProvider(backend: backend)) _globalFetcher = StateObject(wrappedValue: GlobalFetcher(backend: backend)) _nearbyFetcher = StateObject(wrappedValue: NearbyFetcher(backend: backend)) _predictionsFetcher = StateObject(wrappedValue: PredictionsFetcher(socket: socket)) @@ -59,6 +61,7 @@ struct IOSApp: App { ContentView() .environmentObject(locationDataManager) .environmentObject(alertsFetcher) + .environmentObject(backendProvider) .environmentObject(globalFetcher) .environmentObject(nearbyFetcher) .environmentObject(predictionsFetcher) diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index eb8043487..74720e4e0 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -27,6 +27,9 @@ }, "Couldn't load nearby transit, unable to parse response" : { + }, + "Departures" : { + }, "Detour" : { @@ -42,6 +45,9 @@ }, "Failed to load predictions, could not connect to the server" : { + }, + "Live departures" : { + }, "Loading..." : { @@ -61,10 +67,10 @@ "No Service" : { }, - "Route: %@" : { + "Routes" : { }, - "Routes" : { + "Scheduled departures" : { }, "Shuttle" : { @@ -72,9 +78,6 @@ }, "Stop Closed" : { - }, - "Stop: %@" : { - }, "Stops" : { diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyRouteView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyRouteView.swift index b76ddecf0..3629fd731 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyRouteView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyRouteView.swift @@ -14,13 +14,10 @@ struct NearbyRouteView: View { let now: Instant var body: some View { - Section { + RoutePillSection(route: nearbyRoute.route) { ForEach(nearbyRoute.patternsByStop, id: \.stop.id) { patternsAtStop in NearbyStopView(patternsAtStop: patternsAtStop, now: now) } } - header: { - RoutePill(route: nearbyRoute.route).padding(.leading, -20) - } } } diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift index b72d87566..efd7645c0 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift @@ -8,22 +8,78 @@ import Foundation import shared +import SwiftPhoenixClient import SwiftUI struct StopDetailsPage: View { + @ObservedObject var globalFetcher: GlobalFetcher + @StateObject var scheduleFetcher: ScheduleFetcher + @StateObject var predictionsFetcher: PredictionsFetcher var stop: Stop var route: Route? + @State var now = Date.now + + let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + init(backend: any BackendProtocol, socket: any PhoenixSocket, globalFetcher: GlobalFetcher, + stop: Stop, route: Route?) + { + self.globalFetcher = globalFetcher + _scheduleFetcher = StateObject(wrappedValue: ScheduleFetcher(backend: backend)) + _predictionsFetcher = StateObject(wrappedValue: PredictionsFetcher(socket: socket)) + self.stop = stop + self.route = route + } var body: some View { - Text("Stop: \(stop.name)") - .navigationTitle("Stop Details") - Text("Route: \(route?.longName ?? "-")") + VStack { + if predictionsFetcher.predictions != nil { + Text("Live departures") + } else if scheduleFetcher.schedules != nil { + Text("Scheduled departures") + } else { + Text("Departures") + } + if let globalResponse = globalFetcher.response { + StopDetailsRoutesView(departures: StopDetailsDepartures( + stop: stop, + global: globalResponse, + schedules: scheduleFetcher.schedules, + predictions: predictionsFetcher.predictions, + filterAtTime: now.toKotlinInstant() + ), now: now.toKotlinInstant()) + } else { + ProgressView() + } + } + .navigationTitle(stop.name) + .onAppear { + getSchedule() + joinPredictions() + } + .onReceive(timer) { input in + now = input + } + .onDisappear { + leavePredictions() + } } -} -#Preview { - StopDetailsPage( - stop: ObjectCollectionBuilder.Single.shared.stop { $0.name = "Boylston" }, - route: ObjectCollectionBuilder.Single.shared.route { $0.longName = "Green Line B" } - ) + func getSchedule() { + Task { + await scheduleFetcher.getSchedule(stopIds: [stop.id]) + } + } + + func joinPredictions() { + Task { + predictionsFetcher.run(stopIds: [stop.id]) + } + } + + func leavePredictions() { + Task { + predictionsFetcher.leave() + } + } } diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift new file mode 100644 index 000000000..a5aa69768 --- /dev/null +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift @@ -0,0 +1,27 @@ +// +// StopDetailsRouteView.swift +// iosApp +// +// Created by Horn, Melody on 2024-04-03. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +import shared +import SwiftUI + +struct StopDetailsRouteView: View { + let patternsByStop: PatternsByStop + let now: Instant + + var body: some View { + RoutePillSection(route: patternsByStop.route) { + ForEach(patternsByStop.patternsByHeadsign, id: \.headsign) { patternsByHeadsign in + NearbyStopRoutePatternView( + headsign: patternsByHeadsign.headsign, + predictions: patternsByHeadsign.format(now: now) + ) + } + } + } +} diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift new file mode 100644 index 000000000..6272aa4c7 --- /dev/null +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift @@ -0,0 +1,65 @@ +// +// StopDetailsRoutesView.swift +// iosApp +// +// Created by Horn, Melody on 2024-04-03. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +import shared +import SwiftUI + +struct StopDetailsRoutesView: View { + let departures: StopDetailsDepartures + let now: Instant + + var body: some View { + List(departures.routes, id: \.route.id) { patternsByStop in + StopDetailsRouteView(patternsByStop: patternsByStop, now: now) + } + } +} + +#Preview { + let objects = ObjectCollectionBuilder() + let route1 = objects.route { route in + route.color = "00843D" + route.longName = "Green Line B" + route.textColor = "FFFFFF" + route.type = .lightRail + } + let route2 = objects.route { route in + route.color = "FFC72C" + route.shortName = "57" + route.textColor = "000000" + route.type = .bus + } + let stop = objects.stop { _ in } + let prediction1 = objects.prediction { prediction in + prediction.departureTime = (Date.now + 5 * 60).toKotlinInstant() + } + let schedule2 = objects.schedule { schedule in + schedule.departureTime = (Date.now + 10 * 60).toKotlinInstant() + } + let prediction2 = objects.prediction { prediction in + prediction.tripId = "something else" + prediction.departureTime = (Date.now + 8 * 60).toKotlinInstant() + } + + return StopDetailsRoutesView(departures: .init(routes: [ + .init(route: route1, stop: stop, patternsByHeadsign: [ + .init(route: route1, headsign: "A", patterns: [], + upcomingTrips: [.init(prediction: prediction1)], + alertsHere: nil), + ]), + .init(route: route2, stop: stop, patternsByHeadsign: [ + .init(route: route2, headsign: "B", patterns: [], + upcomingTrips: [.init(prediction: prediction2)], + alertsHere: nil), + .init(route: route2, headsign: "C", patterns: [], + upcomingTrips: [.init(schedule: schedule2)], + alertsHere: nil), + ]), + ]), now: Date.now.toKotlinInstant()) +} diff --git a/iosApp/iosAppTests/Views/ContentViewTests.swift b/iosApp/iosAppTests/Views/ContentViewTests.swift index 7c8de931f..a4b358202 100644 --- a/iosApp/iosAppTests/Views/ContentViewTests.swift +++ b/iosApp/iosAppTests/Views/ContentViewTests.swift @@ -31,6 +31,7 @@ final class ContentViewTests: XCTestCase { let sut = ContentView() .environmentObject(LocationDataManager(locationFetcher: MockLocationFetcher())) .environmentObject(AlertsFetcher(socket: FakeSocket())) + .environmentObject(BackendProvider(backend: IdleBackend())) .environmentObject(GlobalFetcher(backend: IdleBackend())) .environmentObject(NearbyFetcher(backend: IdleBackend())) .environmentObject(PredictionsFetcher(socket: FakeSocket())) @@ -60,6 +61,7 @@ final class ContentViewTests: XCTestCase { let sut = ContentView() .environmentObject(LocationDataManager(locationFetcher: MockLocationFetcher())) .environmentObject(AlertsFetcher(socket: FakeSocket())) + .environmentObject(BackendProvider(backend: IdleBackend())) .environmentObject(GlobalFetcher(backend: IdleBackend())) .environmentObject(NearbyFetcher(backend: IdleBackend())) .environmentObject(PredictionsFetcher(socket: FakeSocket())) @@ -109,6 +111,7 @@ final class ContentViewTests: XCTestCase { let sut = ContentView() .environmentObject(LocationDataManager(locationFetcher: MockLocationFetcher())) .environmentObject(AlertsFetcher(socket: FakeSocket())) + .environmentObject(BackendProvider(backend: IdleBackend())) .environmentObject(GlobalFetcher(backend: FakeGlobalFetcherBackend(expectation: fetchesGlobalData))) .environmentObject(NearbyFetcher(backend: IdleBackend())) .environmentObject(PredictionsFetcher(socket: FakeSocket())) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt index b7c2d840d..007fbcb96 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt @@ -208,35 +208,19 @@ fun NearbyStaticData.withRealtimeInfo( filterAtTime: Instant ): List { // add predictions and apply filtering - val schedulesMap = - schedules?.let { scheduleData -> - scheduleData.schedules.groupBy { schedule -> + val upcomingTripsByHeadsignAndStop = + UpcomingTrip.tripsMappedBy( + schedules, + predictions, + scheduleKey = { schedule, scheduleData -> val trip = scheduleData.trips.getValue(schedule.tripId) UpcomingTripKey(schedule.routeId, trip.headsign, schedule.stopId) - } - } - val predictionsMap = - predictions?.let { streamData -> - streamData.predictions.values.groupBy { prediction -> + }, + predictionKey = { prediction, streamData -> val trip = streamData.trips.getValue(prediction.tripId) UpcomingTripKey(prediction.routeId, trip.headsign, prediction.stopId) } - } - val upcomingTripsByHeadsignAndStop = - if (schedulesMap != null || predictionsMap != null) { - ((schedulesMap?.keys ?: emptySet()) + (predictionsMap?.keys ?: emptySet())) - .associateWith { upcomingTripKey -> - val schedulesHere = schedulesMap?.get(upcomingTripKey) - val predictionsHere = predictionsMap?.get(upcomingTripKey) - UpcomingTrip.tripsFromData( - schedulesHere ?: emptyList(), - predictionsHere ?: emptyList(), - predictions?.vehicles ?: emptyMap() - ) - } - } else { - null - } + ) val cutoffTime = filterAtTime.plus(90.minutes) val activeRelevantAlerts = diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt new file mode 100644 index 000000000..f23116c93 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt @@ -0,0 +1,85 @@ +package com.mbta.tid.mbta_app.model + +import com.mbta.tid.mbta_app.model.response.GlobalResponse +import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse +import com.mbta.tid.mbta_app.model.response.ScheduleResponse +import kotlin.time.Duration.Companion.minutes +import kotlinx.datetime.Instant + +data class StopDetailsDepartures(val routes: List) { + constructor( + stop: Stop, + global: GlobalResponse, + schedules: ScheduleResponse?, + predictions: PredictionsStreamDataResponse?, + filterAtTime: Instant + ) : this( + global.run { + data class UpcomingTripKey(val routeId: String, val headsign: String?) + + val upcomingTripsByHeadsignAndStop = + UpcomingTrip.tripsMappedBy( + schedules, + predictions, + scheduleKey = { schedule, scheduleData -> + val trip = scheduleData.trips.getValue(schedule.tripId) + UpcomingTripKey(schedule.routeId, trip.headsign) + }, + predictionKey = { prediction, streamData -> + val trip = streamData.trips.getValue(prediction.tripId) + UpcomingTripKey(prediction.routeId, trip.headsign) + } + ) + val cutoffTime = filterAtTime.plus(90.minutes) + + val allStopIds = + if (patternIdsByStop.containsKey(stop.id)) { + listOf(stop.id) + } else { + stop.childStopIds + } + + val patternsByRoute = + allStopIds + .flatMap { patternIdsByStop[it] ?: emptyList() } + .map { patternId -> routePatterns.getValue(patternId) } + .groupBy { routes.getValue(it.routeId) } + + patternsByRoute + .map { (route, routePatterns) -> + val patternsByHeadsign = + routePatterns.groupBy { + val representativeTrip = trips.getValue(it.representativeTripId) + representativeTrip.headsign + } + + PatternsByStop( + route, + stop, + patternsByHeadsign + .map { (headsign, patterns) -> + val upcomingTrips = + if (upcomingTripsByHeadsignAndStop != null) { + val tripKey = UpcomingTripKey(route.id, headsign) + upcomingTripsByHeadsignAndStop[tripKey] ?: emptyList() + } else { + null + } + + PatternsByHeadsign(route, headsign, patterns, upcomingTrips) + } + .filter { + (it.isTypical() || it.isUpcomingBefore(cutoffTime)) && + !it.isArrivalOnly() + } + .sorted() + ) + } + .filterNot { it.patternsByHeadsign.isEmpty() } + .sortedWith( + (compareBy(Route.subwayFirstComparator) { it.route }) + .then(compareBy { it.route }) + ) + } + ) +} diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/UpcomingTrip.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/UpcomingTrip.kt index 4f38a2b63..bbb0b4321 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/UpcomingTrip.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/UpcomingTrip.kt @@ -1,5 +1,7 @@ package com.mbta.tid.mbta_app.model +import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse +import com.mbta.tid.mbta_app.model.response.ScheduleResponse import kotlin.math.roundToInt import kotlin.time.DurationUnit import kotlinx.datetime.Instant @@ -132,6 +134,40 @@ data class UpcomingTrip( } companion object { + fun tripsMappedBy( + schedules: ScheduleResponse?, + predictions: PredictionsStreamDataResponse?, + scheduleKey: (Schedule, ScheduleResponse) -> Key, + predictionKey: (Prediction, PredictionsStreamDataResponse) -> Key + ): Map>? { + val schedulesMap = + schedules?.let { scheduleData -> + scheduleData.schedules.groupBy { schedule -> + scheduleKey(schedule, scheduleData) + } + } + val predictionsMap = + predictions?.let { predictionData -> + predictionData.predictions.values.groupBy { prediction -> + predictionKey(prediction, predictionData) + } + } + return if (schedulesMap != null || predictionsMap != null) { + ((schedulesMap?.keys ?: emptySet()) + (predictionsMap?.keys ?: emptySet())) + .associateWith { upcomingTripKey -> + val schedulesHere = schedulesMap?.get(upcomingTripKey) + val predictionsHere = predictionsMap?.get(upcomingTripKey) + tripsFromData( + schedulesHere ?: emptyList(), + predictionsHere ?: emptyList(), + predictions?.vehicles ?: emptyMap() + ) + } + } else { + null + } + } + /** * Gets the list of [UpcomingTrip]s from the given [schedules], [predictions] and * [vehicles]. Matches by trip ID, stop ID, and stop sequence. diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDeparturesTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDeparturesTest.kt new file mode 100644 index 000000000..488329714 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDeparturesTest.kt @@ -0,0 +1,79 @@ +package com.mbta.tid.mbta_app.model + +import com.mbta.tid.mbta_app.model.response.GlobalResponse +import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse +import com.mbta.tid.mbta_app.model.response.ScheduleResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.Instant + +class StopDetailsDeparturesTest { + @Test + fun `StopDetailsDepartures finds trips`() { + val objects = ObjectCollectionBuilder() + + val route = objects.route() + val routePattern1 = objects.routePattern(route) { representativeTrip { headsign = "A" } } + val routePattern2 = + objects.routePattern(route) { + representativeTrip { headsign = "B" } + typicality = RoutePattern.Typicality.Typical + } + val stop = objects.stop() + + val time1 = Instant.parse("2024-04-02T16:29:22Z") + + val trip1 = objects.trip(routePattern1) + val schedule1 = + objects.schedule { + this.trip = trip1 + stopId = stop.id + departureTime = time1 + stopSequence = 4 + } + val prediction1 = objects.prediction(schedule1) { departureTime = time1 } + + val time2 = Instant.parse("2024-04-02T17:11:31Z") + val trip2 = objects.trip(routePattern1) + val schedule2 = + objects.schedule { + this.trip = trip2 + stopId = stop.id + departureTime = time2 + stopSequence = 4 + } + + assertEquals( + StopDetailsDepartures( + listOf( + PatternsByStop( + route, + stop, + listOf( + PatternsByHeadsign( + route, + "A", + listOf(routePattern1), + listOf( + UpcomingTrip(schedule1, prediction1), + UpcomingTrip(schedule2) + ) + ), + PatternsByHeadsign(route, "B", listOf(routePattern2), listOf()) + ) + ) + ) + ), + StopDetailsDepartures( + stop, + GlobalResponse( + objects, + mapOf(stop.id to listOf(routePattern1.id, routePattern2.id)) + ), + ScheduleResponse(objects), + PredictionsStreamDataResponse(objects), + filterAtTime = time1 + ) + ) + } +}