diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitPageTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitPageTest.kt index 043b6827b..13c1cec63 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitPageTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitPageTest.kt @@ -195,6 +195,8 @@ class NearbyTransitPageTest : KoinTest { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { /* no-op */ } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitViewTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitViewTest.kt index 2a5ed4700..199fa233b 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitViewTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitViewTest.kt @@ -189,6 +189,8 @@ class NearbyTransitViewTest : KoinTest { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { /* no-op */ } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewTest.kt index 0889a5f13..3dc169b64 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewTest.kt @@ -135,6 +135,8 @@ class StopDetailsViewTest { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { /* no-op */ } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/util/SubscribeToPredictionsTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/util/SubscribeToPredictionsTest.kt index 2b9864f06..0cf397918 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/util/SubscribeToPredictionsTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/util/SubscribeToPredictionsTest.kt @@ -59,6 +59,8 @@ class SubscribeToPredictionsTest { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { check(isConnected) { "called disconnect when not connected" } isConnected = false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68b939f69..d3e2e5a13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidx-navigationCompose = "2.8.1" agp = "8.5.2" # see https://developer.android.com/develop/ui/compose/bom/bom-mapping # and https://developer.android.com/jetpack/androidx/releases/compose for release notes -compose-bom = "2024.09.00" +compose-bom = "2024.09.03" datastorePreferencesCore = "1.1.1" kotlin = "2.0.20" javaphoenixclient = "1.3.1" @@ -18,7 +18,7 @@ mapbox = "11.3.0" mapboxTurf = "6.15.0" mokkery = "2.3.0" okhttp = "4.12.0" -okio = "3.9.0" +okio = "3.9.1" sentry = "0.9.0" skie = "0.9.2" spatialk = "0.3.0" diff --git a/iosApp/iosApp/ComponentViews/ErrorBanner.swift b/iosApp/iosApp/ComponentViews/ErrorBanner.swift index 229435b85..baa24c48c 100644 --- a/iosApp/iosApp/ComponentViews/ErrorBanner.swift +++ b/iosApp/iosApp/ComponentViews/ErrorBanner.swift @@ -13,9 +13,12 @@ struct ErrorBanner: View { var repo: IErrorBannerStateRepository @State var state: ErrorBannerState? + let loadingWhenPredictionsStale: Bool + let inspection = Inspection() - init(repo: IErrorBannerStateRepository = RepositoryDI().errorBanner) { + init(loadingWhenPredictionsStale: Bool, repo: IErrorBannerStateRepository = RepositoryDI().errorBanner) { + self.loadingWhenPredictionsStale = loadingWhenPredictionsStale self.repo = repo state = repo.state.value } @@ -42,17 +45,21 @@ struct ErrorBanner: View { })) } case let .stalePredictions(state): - IconCard( - iconName: "clock.arrow.circlepath", - details: Text("Updated \(state.minutesAgo(), specifier: "%ld") minutes ago") - ) { - AnyView(Button(action: { - repo.clearState() - state.action() - }, label: { - Image(systemName: "arrow.clockwise") - .accessibilityLabel("Refresh predictions") - })) + if loadingWhenPredictionsStale { + ProgressView() + } else { + IconCard( + iconName: "clock.arrow.circlepath", + details: Text("Updated \(state.minutesAgo(), specifier: "%ld") minutes ago") + ) { + AnyView(Button(action: { + repo.clearState() + state.action() + }, label: { + Image(systemName: "arrow.clockwise") + .accessibilityLabel("Refresh predictions") + })) + } } case nil: // for some reason, .collect on an EmptyView doesn't work @@ -62,7 +69,15 @@ struct ErrorBanner: View { } #Preview { - ErrorBanner(repo: MockErrorBannerStateRepository( - state: .StalePredictions(lastUpdated: Date.now.addingTimeInterval(-2 * 60).toKotlinInstant(), action: {}) - )) + VStack(spacing: 16) { + ErrorBanner(loadingWhenPredictionsStale: false, repo: MockErrorBannerStateRepository( + state: .DataError(action: {}) + )) + ErrorBanner(loadingWhenPredictionsStale: false, repo: MockErrorBannerStateRepository( + state: .StalePredictions(lastUpdated: Date.now.addingTimeInterval(-2 * 60).toKotlinInstant(), action: {}) + )) + ErrorBanner(loadingWhenPredictionsStale: true, repo: MockErrorBannerStateRepository( + state: .StalePredictions(lastUpdated: Date.now.addingTimeInterval(-2 * 60).toKotlinInstant(), action: {}) + )) + } } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 45073537e..e293b245f 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,8 +1,8 @@ import CoreLocation +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI -@_spi(Experimental) import MapboxMaps struct ContentView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/iosApp/iosApp/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index 1fa97c6da..53d597b7e 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -7,8 +7,8 @@ // import Foundation -import shared @_spi(Experimental) import MapboxMaps +import shared class ContentViewModel: ObservableObject { @Published var configResponse: ApiResult? diff --git a/iosApp/iosApp/Fetchers/ViewportProvider.swift b/iosApp/iosApp/Fetchers/ViewportProvider.swift index ca0f958af..870c5d3b9 100644 --- a/iosApp/iosApp/Fetchers/ViewportProvider.swift +++ b/iosApp/iosApp/Fetchers/ViewportProvider.swift @@ -7,10 +7,9 @@ // import Combine -import shared -import SwiftUI @_spi(Experimental) import MapboxMaps import shared +import SwiftUI class ViewportProvider: ObservableObject { enum Defaults { diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 7cea8a52b..1aa367987 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -347,6 +347,9 @@ }, "select location" : { + }, + "Service Change" : { + "comment" : "Possible alert effect" }, "Service ended" : { diff --git a/iosApp/iosApp/Pages/AlertDetails/AlertDetails.swift b/iosApp/iosApp/Pages/AlertDetails/AlertDetails.swift index a06bfc7ce..b6a4f3477 100644 --- a/iosApp/iosApp/Pages/AlertDetails/AlertDetails.swift +++ b/iosApp/iosApp/Pages/AlertDetails/AlertDetails.swift @@ -32,6 +32,7 @@ struct AlertDetails: View { switch alert.effect { case .detour: NSLocalizedString("Detour", comment: "Possible alert effect") case .dockClosure: NSLocalizedString("Dock Closure", comment: "Possible alert effect") + case .serviceChange: NSLocalizedString("Service Change", comment: "Possible alert effect") case .shuttle: NSLocalizedString("Shuttle", comment: "Possible alert effect") case .stationClosure: NSLocalizedString("Station Closure", comment: "Possible alert effect") case .stopClosure: NSLocalizedString("Stop Closure", comment: "Possible alert effect") diff --git a/iosApp/iosApp/Pages/Map/AnnotatedMap.swift b/iosApp/iosApp/Pages/Map/AnnotatedMap.swift index 9aa63a71e..d81497f22 100644 --- a/iosApp/iosApp/Pages/Map/AnnotatedMap.swift +++ b/iosApp/iosApp/Pages/Map/AnnotatedMap.swift @@ -6,9 +6,9 @@ // Copyright © 2024 MBTA. All rights reserved. // +@_spi(Experimental) import MapboxMaps import shared import SwiftUI -@_spi(Experimental) import MapboxMaps struct AnnotatedMap: View { static let annotationTextZoomThreshold = 19.0 @@ -28,6 +28,7 @@ struct AnnotatedMap: View { @ObservedObject var viewportProvider: ViewportProvider @Environment(\.colorScheme) var colorScheme + @Environment(\.scenePhase) var scenePhase var handleCameraChange: (CameraChanged) -> Void var handleStyleLoaded: () -> Void @@ -53,13 +54,18 @@ struct AnnotatedMap: View { // The initial run of this happens before any required data is loaded, so it does nothing and // handleTryLayerInit always performs the first layer creation, but once the data is in place, // this handles any time the map is reloaded again, like for a light/dark mode switch. - handleStyleLoaded() + if scenePhase == .active { + // onStyleLoaded was unexpectedly called when app moved to background because the colorScheme + // changes twice while backgrounding. Ensure it is only called when the app is active. + handleStyleLoaded() + } } .additionalSafeAreaInsets(.bottom, sheetHeight) .accessibilityIdentifier("transitMap") .onReceive(viewportProvider.cameraStatePublisher) { newCameraState in zoomLevel = newCameraState.zoom } + .withScenePhaseHandlers(onActive: onActive) .task { do { mapDebug = try await getSettingUsecase.execute(setting: .map).boolValue @@ -69,6 +75,11 @@ struct AnnotatedMap: View { } } + func onActive() { + // re-load styles in case the colorScheme changed while in the background + handleStyleLoaded() + } + private var allVehicles: [Vehicle]? { switch (vehicles, selectedVehicle) { case (.none, .none): nil diff --git a/iosApp/iosApp/Pages/Map/AnnotationLabel.swift b/iosApp/iosApp/Pages/Map/AnnotationLabel.swift index 1a7d02bde..69df1717c 100644 --- a/iosApp/iosApp/Pages/Map/AnnotationLabel.swift +++ b/iosApp/iosApp/Pages/Map/AnnotationLabel.swift @@ -6,8 +6,8 @@ // Copyright © 2024 MBTA. All rights reserved. // -import SwiftUI @_spi(Experimental) import MapboxMaps +import SwiftUI extension View { func annotationLabel(_ content: Content) -> ModifiedContent> { diff --git a/iosApp/iosApp/Pages/Map/HomeMapView.swift b/iosApp/iosApp/Pages/Map/HomeMapView.swift index a8c0642ee..929f2d757 100644 --- a/iosApp/iosApp/Pages/Map/HomeMapView.swift +++ b/iosApp/iosApp/Pages/Map/HomeMapView.swift @@ -6,10 +6,10 @@ // Copyright © 2024 MBTA. All rights reserved. // +@_spi(Experimental) import MapboxMaps import os import shared import SwiftUI -@_spi(Experimental) import MapboxMaps struct HomeMapView: View { var analytics: NearbyTransitAnalytics = AnalyticsProvider.shared @@ -34,8 +34,6 @@ struct HomeMapView: View { @State var vehiclesRepository: IVehiclesRepository @State var vehiclesData: [Vehicle]? - @State var upcomingRoutePatterns: Set = .init() - @StateObject var locationDataManager: LocationDataManager @Binding var sheetHeight: CGFloat diff --git a/iosApp/iosApp/Pages/Map/HomeMapViewHandlerExtension.swift b/iosApp/iosApp/Pages/Map/HomeMapViewHandlerExtension.swift index 55e796b06..9856ed69d 100644 --- a/iosApp/iosApp/Pages/Map/HomeMapViewHandlerExtension.swift +++ b/iosApp/iosApp/Pages/Map/HomeMapViewHandlerExtension.swift @@ -6,9 +6,9 @@ // Copyright © 2024 MBTA. All rights reserved. // +@_spi(Experimental) import MapboxMaps import shared import SwiftUI -@_spi(Experimental) import MapboxMaps /* Functions for handling interactions with the map, like prop change, navigation, and tapping. @@ -101,7 +101,9 @@ extension HomeMapView { leaveVehiclesChannel() vehiclesRepository.connect(routeId: routeId, directionId: directionId) { outcome in if case let .ok(result) = onEnum(of: outcome) { - vehiclesData = Array(result.data.vehicles.values) + if let departures = nearbyVM.departures { + vehiclesData = Array(departures.filterVehiclesByUpcoming(vehicles: result.data).values) + } } } } diff --git a/iosApp/iosApp/Pages/Map/HomeMapViewLayerExtension.swift b/iosApp/iosApp/Pages/Map/HomeMapViewLayerExtension.swift index ddaa0f216..538ba3a2d 100644 --- a/iosApp/iosApp/Pages/Map/HomeMapViewLayerExtension.swift +++ b/iosApp/iosApp/Pages/Map/HomeMapViewLayerExtension.swift @@ -6,9 +6,9 @@ // Copyright © 2024 MBTA. All rights reserved. // +@_spi(Experimental) import MapboxMaps import shared import SwiftUI -@_spi(Experimental) import MapboxMaps /* Functions for manipulating the layers displayed on the map. @@ -60,13 +60,13 @@ extension HomeMapView { func refreshMap() { if let layerManager = mapVM.layerManager { + updateGlobalMapDataSources() if layerManager.currentScheme != colorScheme { layerManager.addIcons(recreate: true) addLayers(layerManager, recreate: true) } else { addLayers(layerManager) } - updateGlobalMapDataSources() } } diff --git a/iosApp/iosApp/Pages/Map/MapLayerManager.swift b/iosApp/iosApp/Pages/Map/MapLayerManager.swift index d82518cf6..605d41a04 100644 --- a/iosApp/iosApp/Pages/Map/MapLayerManager.swift +++ b/iosApp/iosApp/Pages/Map/MapLayerManager.swift @@ -6,10 +6,10 @@ // Copyright © 2024 MBTA. All rights reserved. // +@_spi(Experimental) import MapboxMaps import os import shared import SwiftUI -@_spi(Experimental) import MapboxMaps protocol IMapLayerManager { var currentScheme: ColorScheme? { get } @@ -56,6 +56,13 @@ class MapLayerManager: IMapLayerManager { } } + /* + Adds persistent layers so that they are persisted even if the underlying map style changes. To intentionally + re-create the layers due to a change that corresponds with a style change (such as colorScheme changing), + set recreate to true. + + https://docs.mapbox.com/ios/maps/api/11.5.0/documentation/mapboxmaps/stylemanager/addpersistentlayer(_:layerposition:) + */ func addLayers(colorScheme: ColorScheme, recreate: Bool = false) { let colorPalette = getColorPalette(colorScheme: colorScheme) currentScheme = colorScheme @@ -76,9 +83,9 @@ class MapLayerManager: IMapLayerManager { } if map.layerExists(withId: "puck") { - try map.addLayer(layer, layerPosition: .below("puck")) + try map.addPersistentLayer(layer, layerPosition: .below("puck")) } else { - try map.addLayer(layer) + try map.addPersistentLayer(layer) } } catch { Logger().error("Failed to add layer \(layer.id)\n\(error)") diff --git a/iosApp/iosApp/Pages/Map/MapViewModel.swift b/iosApp/iosApp/Pages/Map/MapViewModel.swift index e38279fdc..2e45fcb5d 100644 --- a/iosApp/iosApp/Pages/Map/MapViewModel.swift +++ b/iosApp/iosApp/Pages/Map/MapViewModel.swift @@ -8,8 +8,8 @@ import Combine import Foundation -import shared @_spi(Experimental) import MapboxMaps +import shared class MapViewModel: ObservableObject { @Published var selectedVehicle: Vehicle? diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift index 74357520b..1b69ec96d 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift @@ -8,16 +8,17 @@ import Combine import CoreLocation +@_spi(Experimental) import MapboxMaps import os import shared import SwiftUI -@_spi(Experimental) import MapboxMaps struct NearbyTransitPageView: View { @ObservedObject var nearbyVM: NearbyViewModel @ObservedObject var viewportProvider: ViewportProvider @State var location: CLLocationCoordinate2D? + @State var isReturningFromBackground = false let inspection = Inspection() @@ -34,7 +35,7 @@ struct NearbyTransitPageView: View { Color.fill1.ignoresSafeArea(.all) VStack { SheetHeader(title: String(localized: "Nearby Transit", comment: "Header for nearby transit sheet")) - ErrorBanner() + ErrorBanner(loadingWhenPredictionsStale: isReturningFromBackground) if viewportProvider.isManuallyCentering { LoadingCard { Text("select location") } } else { @@ -44,6 +45,7 @@ struct NearbyTransitPageView: View { }, state: $nearbyVM.nearbyState, location: $location, + isReturningFromBackground: $isReturningFromBackground, nearbyVM: nearbyVM ) diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift index 810158d21..3a9c75d8d 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift @@ -9,10 +9,10 @@ import Combine import CoreLocation import FirebaseAnalytics +@_spi(Experimental) import MapboxMaps import os import shared import SwiftUI -@_spi(Experimental) import MapboxMaps struct NearbyTransitView: View { var analytics: NearbyTransitAnalytics = AnalyticsProvider.shared @@ -23,6 +23,7 @@ struct NearbyTransitView: View { var getNearby: (GlobalResponse, CLLocationCoordinate2D) -> Void @Binding var state: NearbyViewModel.NearbyTransitState @Binding var location: CLLocationCoordinate2D? + @Binding var isReturningFromBackground: Bool var globalRepository = RepositoryDI().global @State var globalData: GlobalResponse? @ObservedObject var nearbyVM: NearbyViewModel @@ -82,14 +83,24 @@ struct NearbyTransitView: View { while !Task.isCancelled { now = Date.now updateNearbyRoutes() - await checkPredictionsStale() + checkPredictionsStale() try? await Task.sleep(for: .seconds(5)) } } .withScenePhaseHandlers( - onActive: { joinPredictions(state.nearbyByRouteAndStop?.stopIds()) }, + onActive: { + if let predictionsByStop, + predictionsRepository + .shouldForgetPredictions(predictionCount: predictionsByStop.predictionQuantity()) { + self.predictionsByStop = nil + } + joinPredictions(state.nearbyByRouteAndStop?.stopIds()) + }, onInactive: leavePredictions, - onBackground: leavePredictions + onBackground: { + leavePredictions() + isReturningFromBackground = true + } ) } @@ -191,9 +202,12 @@ struct NearbyTransitView: View { predictionsRepository.connectV2(stopIds: Array(stopIds), onJoin: { outcome in DispatchQueue.main.async { switch onEnum(of: outcome) { - case let .ok(result): predictionsByStop = result.data + case let .ok(result): + predictionsByStop = result.data + checkPredictionsStale() case .error: break } + isReturningFromBackground = false } }, onMessage: { outcome in DispatchQueue.main.async { @@ -208,8 +222,10 @@ struct NearbyTransitView: View { vehicles: result.data.vehicles ) } + checkPredictionsStale() case .error: break } + isReturningFromBackground = false } }) @@ -248,7 +264,7 @@ struct NearbyTransitView: View { } } - private func checkPredictionsStale() async { + private func checkPredictionsStale() { if let lastPredictions = predictionsRepository.lastUpdated { errorBannerRepository.checkPredictionsStale( predictionsLastUpdated: lastPredictions, diff --git a/iosApp/iosApp/Pages/Search/SearchViewModel.swift b/iosApp/iosApp/Pages/Search/SearchViewModel.swift index ad0dbc9d4..316a8b480 100644 --- a/iosApp/iosApp/Pages/Search/SearchViewModel.swift +++ b/iosApp/iosApp/Pages/Search/SearchViewModel.swift @@ -6,8 +6,8 @@ // Copyright © 2024 MBTA. All rights reserved. // -import shared @_spi(Experimental) import MapboxMaps +import shared class SearchViewModel: ObservableObject { @Published var routeResultsEnabled: Bool diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift index 8a40d4ad0..3e1b9b5cd 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift @@ -23,6 +23,8 @@ struct StopDetailsPage: View { @State var predictionsRepository: IPredictionsRepository var stop: Stop + @State var isReturningFromBackground = false + var filter: StopDetailsFilter? // StopDetailsPage maintains its own internal state of the departures presented. // This way, when transitioning between one StopDetailsPage and another, each separate page shows @@ -70,7 +72,8 @@ struct StopDetailsPage: View { nearbyVM: nearbyVM, now: now, pinnedRoutes: pinnedRoutes, - togglePinnedRoute: togglePinnedRoute + togglePinnedRoute: togglePinnedRoute, + isReturningFromBackground: isReturningFromBackground ) .onAppear { loadEverything() @@ -96,16 +99,27 @@ struct StopDetailsPage: View { while !Task.isCancelled { now = Date.now updateDepartures() - await checkPredictionsStale() + checkPredictionsStale() try? await Task.sleep(for: .seconds(5)) } } .onDisappear { leavePredictions() } - .withScenePhaseHandlers(onActive: { joinPredictions(stop) }, - onInactive: leavePredictions, - onBackground: leavePredictions) + .withScenePhaseHandlers( + onActive: { + if let predictionsByStop, + predictionsRepository + .shouldForgetPredictions(predictionCount: predictionsByStop.predictionQuantity()) { + self.predictionsByStop = nil + } + joinPredictions(stop) + }, + onInactive: leavePredictions, + onBackground: { leavePredictions() + isReturningFromBackground = true + } + ) } } @@ -185,7 +199,9 @@ struct StopDetailsPage: View { DispatchQueue.main.async { if case let .ok(result) = onEnum(of: outcome) { predictionsByStop = result.data + checkPredictionsStale() } + isReturningFromBackground = false } }, onMessage: { outcome in DispatchQueue.main.async { @@ -199,7 +215,9 @@ struct StopDetailsPage: View { vehicles: result.data.vehicles ) } + checkPredictionsStale() } + isReturningFromBackground = false } }) @@ -209,7 +227,7 @@ struct StopDetailsPage: View { predictionsRepository.disconnect() } - private func checkPredictionsStale() async { + private func checkPredictionsStale() { if let lastPredictions = predictionsRepository.lastUpdated { errorBannerRepository.checkPredictionsStale( predictionsLastUpdated: lastPredictions, diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsView.swift index f9ff69bd5..88da11ce5 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsView.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsView.swift @@ -27,6 +27,7 @@ struct StopDetailsView: View { @ObservedObject var nearbyVM: NearbyViewModel let pinnedRoutes: Set @State var predictions: PredictionsStreamDataResponse? + let isReturningFromBackground: Bool let togglePinnedRoute: (String) -> Void @@ -43,7 +44,8 @@ struct StopDetailsView: View { nearbyVM: NearbyViewModel, now: Date, pinnedRoutes: Set, - togglePinnedRoute: @escaping (String) -> Void + togglePinnedRoute: @escaping (String) -> Void, + isReturningFromBackground: Bool ) { self.errorBannerRepository = errorBannerRepository self.globalRepository = globalRepository @@ -55,6 +57,7 @@ struct StopDetailsView: View { self.now = now self.pinnedRoutes = pinnedRoutes self.togglePinnedRoute = togglePinnedRoute + self.isReturningFromBackground = isReturningFromBackground if let departures { servedRoutes = departures.routes.map { patterns in @@ -79,7 +82,7 @@ struct StopDetailsView: View { onBack: nearbyVM.navigationStack.count > 1 ? { nearbyVM.goBack() } : nil, onClose: { nearbyVM.navigationStack.removeAll() } ) - ErrorBanner() + ErrorBanner(loadingWhenPredictionsStale: isReturningFromBackground) if servedRoutes.count > 1 { StopDetailsFilterPills( servedRoutes: servedRoutes, diff --git a/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift b/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift index f3471fe3e..38601b21a 100644 --- a/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift +++ b/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift @@ -29,6 +29,8 @@ struct TripDetailsPage: View { @State var vehicleRepository: IVehicleRepository @State var vehicleResponse: VehicleStreamDataResponse? + @State var isReturningFromBackground = false + var errorBannerRepository: IErrorBannerStateRepository let analytics: TripDetailsAnalytics @@ -80,7 +82,7 @@ struct TripDetailsPage: View { vehicle: vehicle, alertsData: nearbyVM.alerts, globalData: globalResponse ) { vehicleCardView - ErrorBanner() + ErrorBanner(loadingWhenPredictionsStale: isReturningFromBackground) if let target, let stopSequence = target.stopSequence, let splitStops = stops.splitForTarget( targetStopId: target.stopId, targetStopSequence: Int32(stopSequence), @@ -108,7 +110,7 @@ struct TripDetailsPage: View { .task { now = Date.now.toKotlinInstant() while !Task.isCancelled { - await checkPredictionsStale() + checkPredictionsStale() do { try await Task.sleep(for: .seconds(1)) } catch { @@ -128,9 +130,21 @@ struct TripDetailsPage: View { joinVehicle(vehicleId: vehicleId) } .onReceive(inspection.notice) { inspection.visit(self, $0) } - .withScenePhaseHandlers(onActive: joinRealtime, - onInactive: leaveRealtime, - onBackground: leaveRealtime) + .withScenePhaseHandlers( + onActive: { + if let tripPredictions, + tripPredictionsRepository + .shouldForgetPredictions(predictionCount: tripPredictions.predictionQuantity()) { + self.tripPredictions = nil + } + joinRealtime() + }, + onInactive: leaveRealtime, + onBackground: { + leaveRealtime() + isReturningFromBackground = true + } + ) } private func loadEverything() { @@ -191,11 +205,13 @@ struct TripDetailsPage: View { private func joinPredictions(tripId: String) { tripPredictionsRepository.connect(tripId: tripId) { outcome in DispatchQueue.main.async { + isReturningFromBackground = false // no error handling since persistent errors cause stale predictions switch onEnum(of: outcome) { case let .ok(result): tripPredictions = result.data case .error: break } + checkPredictionsStale() } } } @@ -228,7 +244,7 @@ struct TripDetailsPage: View { } } - private func checkPredictionsStale() async { + private func checkPredictionsStale() { if let lastPredictions = tripPredictionsRepository.lastUpdated { errorBannerRepository.checkPredictionsStale( predictionsLastUpdated: lastPredictions, diff --git a/iosApp/iosApp/Utils/AppVariantExtension.swift b/iosApp/iosApp/Utils/AppVariantExtension.swift index ad8a9260d..6ae152d6c 100644 --- a/iosApp/iosApp/Utils/AppVariantExtension.swift +++ b/iosApp/iosApp/Utils/AppVariantExtension.swift @@ -6,7 +6,7 @@ // Copyright © 2024 MBTA. All rights reserved. // -import MapboxMaps +@_spi(Experimental) import MapboxMaps import shared import SwiftUI diff --git a/iosApp/iosApp/Utils/MapboxBridge.swift b/iosApp/iosApp/Utils/MapboxBridge.swift index dec4c86f9..cd7a7921b 100644 --- a/iosApp/iosApp/Utils/MapboxBridge.swift +++ b/iosApp/iosApp/Utils/MapboxBridge.swift @@ -6,7 +6,7 @@ // Copyright © 2024 MBTA. All rights reserved. // -import MapboxMaps +@_spi(Experimental) import MapboxMaps import shared private func bridgeStyleObject(_ object: MapboxStyleObject) -> T { diff --git a/iosApp/iosAppTests/Fetchers/ViewportProviderTest.swift b/iosApp/iosAppTests/Fetchers/ViewportProviderTest.swift index c77d4b1ce..a50fe8391 100644 --- a/iosApp/iosAppTests/Fetchers/ViewportProviderTest.swift +++ b/iosApp/iosAppTests/Fetchers/ViewportProviderTest.swift @@ -7,8 +7,8 @@ // @testable import iosApp -import shared @_spi(Experimental) import MapboxMaps +import shared import XCTest final class ViewportProviderTest: XCTestCase { diff --git a/iosApp/iosAppTests/Pages/Map/HomeMapViewTest.swift b/iosApp/iosAppTests/Pages/Map/HomeMapViewTest.swift index b3f6575c9..974b05c21 100644 --- a/iosApp/iosAppTests/Pages/Map/HomeMapViewTest.swift +++ b/iosApp/iosAppTests/Pages/Map/HomeMapViewTest.swift @@ -7,12 +7,12 @@ // @testable import iosApp +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI import ViewInspector import XCTest -@_spi(Experimental) import MapboxMaps final class HomeMapViewTest: XCTestCase { override func setUp() { diff --git a/iosApp/iosAppTests/Pages/Map/MapHttpInterceptorTests.swift b/iosApp/iosAppTests/Pages/Map/MapHttpInterceptorTests.swift index 836a20737..49c99deb4 100644 --- a/iosApp/iosAppTests/Pages/Map/MapHttpInterceptorTests.swift +++ b/iosApp/iosAppTests/Pages/Map/MapHttpInterceptorTests.swift @@ -7,8 +7,8 @@ // @testable import iosApp -import XCTest @_spi(Experimental) import MapboxMaps +import XCTest final class MapHttpInterceptorTests: XCTestCase { override func setUp() { diff --git a/iosApp/iosAppTests/Pages/NearbyTransit/NearbyTransitPageViewTests.swift b/iosApp/iosAppTests/Pages/NearbyTransit/NearbyTransitPageViewTests.swift index 8a465ef57..9271c13fd 100644 --- a/iosApp/iosAppTests/Pages/NearbyTransit/NearbyTransitPageViewTests.swift +++ b/iosApp/iosAppTests/Pages/NearbyTransit/NearbyTransitPageViewTests.swift @@ -9,12 +9,12 @@ import Combine import CoreLocation @testable import iosApp +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI import ViewInspector import XCTest -@_spi(Experimental) import MapboxMaps // swiftlint:disable:next type_body_length final class NearbyTransitPageViewTests: XCTestCase { diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift index 7f4844b26..709966278 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift @@ -8,12 +8,12 @@ import Combine @testable import iosApp +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI import ViewInspector import XCTest -@_spi(Experimental) import MapboxMaps final class StopDetailsPageTests: XCTestCase { override func setUp() { diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsViewTests.swift index 81c42c471..745c2541d 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsViewTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsViewTests.swift @@ -41,7 +41,7 @@ final class StopDetailsViewTests: XCTestCase { ]), nearbyVM: .init(), now: Date.now, - pinnedRoutes: [], togglePinnedRoute: { _ in }) + pinnedRoutes: [], togglePinnedRoute: { _ in }, isReturningFromBackground: false) ViewHosting.host(view: sut) let routePills = try sut.inspect().find(StopDetailsFilterPills.self).findAll(RoutePill.self) @@ -65,7 +65,7 @@ final class StopDetailsViewTests: XCTestCase { ]), nearbyVM: .init(), now: Date.now, - pinnedRoutes: [], togglePinnedRoute: { _ in }) + pinnedRoutes: [], togglePinnedRoute: { _ in }, isReturningFromBackground: false) ViewHosting.host(view: sut) XCTAssertNil(try? sut.inspect().find(StopDetailsFilterPills.self)) @@ -84,7 +84,7 @@ final class StopDetailsViewTests: XCTestCase { departures: nil, nearbyVM: nearbyVM, now: Date.now, - pinnedRoutes: [], togglePinnedRoute: { _ in } + pinnedRoutes: [], togglePinnedRoute: { _ in }, isReturningFromBackground: false ) ViewHosting.host(view: sut) @@ -109,7 +109,7 @@ final class StopDetailsViewTests: XCTestCase { departures: nil, nearbyVM: nearbyVM, now: Date.now, - pinnedRoutes: [], togglePinnedRoute: { _ in } + pinnedRoutes: [], togglePinnedRoute: { _ in }, isReturningFromBackground: false ) ViewHosting.host(view: sut) @@ -129,7 +129,7 @@ final class StopDetailsViewTests: XCTestCase { departures: nil, nearbyVM: nearbyVM, now: Date.now, - pinnedRoutes: [], togglePinnedRoute: { _ in } + pinnedRoutes: [], togglePinnedRoute: { _ in }, isReturningFromBackground: false ) ViewHosting.host(view: sut) diff --git a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift index 931a6adfd..a43f65832 100644 --- a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift +++ b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift @@ -655,6 +655,10 @@ final class TripDetailsPageTests: XCTestCase { var lastUpdated: Instant? + func shouldForgetPredictions(predictionCount _: Int32) -> Bool { + false + } + func disconnect() { onDisconnect?() } diff --git a/iosApp/iosAppTests/Views/ContentViewTests.swift b/iosApp/iosAppTests/Views/ContentViewTests.swift index b9c952324..81a419598 100644 --- a/iosApp/iosAppTests/Views/ContentViewTests.swift +++ b/iosApp/iosAppTests/Views/ContentViewTests.swift @@ -8,12 +8,12 @@ import Combine import Foundation @testable import iosApp +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI import ViewInspector import XCTest -@_spi(Experimental) import MapboxMaps final class ContentViewTests: XCTestCase { override func setUp() { diff --git a/iosApp/iosAppTests/Views/ErrorBannerTests.swift b/iosApp/iosAppTests/Views/ErrorBannerTests.swift index b52c9dc38..e637ac54b 100644 --- a/iosApp/iosAppTests/Views/ErrorBannerTests.swift +++ b/iosApp/iosAppTests/Views/ErrorBannerTests.swift @@ -18,7 +18,7 @@ final class ErrorBannerTests: XCTestCase { func testRespondsToState() throws { let repo = MockErrorBannerStateRepository(state: nil) - let sut = ErrorBanner(repo: repo) + let sut = ErrorBanner(loadingWhenPredictionsStale: false, repo: repo) ViewHosting.host(view: sut) @@ -30,7 +30,7 @@ final class ErrorBannerTests: XCTestCase { let stateSetPublisher = PassthroughSubject() - let showedState = sut.inspection.inspect(onReceive: stateSetPublisher, after: 0.2) { view in + let showedState = sut.inspection.inspect(onReceive: stateSetPublisher, after: 0.5) { view in XCTAssertEqual(try view.find(ViewType.Text.self).string(), "Updated \(minutesAgo) minutes ago") try view.find(ViewType.Button.self).tap() @@ -46,4 +46,22 @@ final class ErrorBannerTests: XCTestCase { wait(for: [showedState], timeout: 1) wait(for: [callsAction], timeout: 1) } + + func testLoadingWhenPredictionsStale() throws { + let sut = ErrorBanner( + loadingWhenPredictionsStale: true, + repo: MockErrorBannerStateRepository(state: .StalePredictions( + lastUpdated: Date.distantPast.toKotlinInstant(), + action: {} + )) + ) + + ViewHosting.host(view: sut) + + let showedLoading = sut.inspection.inspect(after: 0.2) { view in + XCTAssertNotNil(try view.find(ViewType.ProgressView.self)) + } + + wait(for: [showedLoading], timeout: 1) + } } diff --git a/iosApp/iosAppTests/Views/HomeMapViewTests.swift b/iosApp/iosAppTests/Views/HomeMapViewTests.swift index 026c7e35f..eae8d1b90 100644 --- a/iosApp/iosAppTests/Views/HomeMapViewTests.swift +++ b/iosApp/iosAppTests/Views/HomeMapViewTests.swift @@ -8,10 +8,10 @@ import Combine @testable import iosApp -import ViewInspector @_spi(Experimental) import MapboxMaps import shared import SwiftUI +import ViewInspector import XCTest // swiftlint:disable:next type_body_length diff --git a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift index 0da74d6e6..a376e54a5 100644 --- a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift +++ b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift @@ -9,12 +9,12 @@ import Combine import CoreLocation @testable import iosApp +@_spi(Experimental) import MapboxMaps import shared import SwiftPhoenixClient import SwiftUI import ViewInspector import XCTest -@_spi(Experimental) import MapboxMaps // swiftlint:disable:next type_body_length final class NearbyTransitViewTests: XCTestCase { @@ -34,6 +34,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(.init()), location: .constant(ViewportProvider.Defaults.center), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) XCTAssertNotNil(try sut.inspect().find(LoadingCard.self)) @@ -50,6 +51,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in getNearbyExpectation.fulfill() }, state: .constant(.init()), location: .constant(ViewportProvider.Defaults.center), + isReturningFromBackground: .constant(false), globalRepository: MockGlobalRepository(), nearbyVM: .init() ) @@ -145,6 +147,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) let exp = sut.on(\.didAppear) { view in @@ -227,6 +230,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init(), scheduleResponse: .init(objects: objects) ) @@ -272,6 +276,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init(), scheduleResponse: .init(objects: objects) ) @@ -341,6 +346,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init(), now: now ) @@ -463,6 +469,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(greenLineState), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), globalRepository: MockGlobalRepository(response: .init(objects: objects, patternIdsByStop: [:])) { globalLoadedPublisher.send() }, @@ -516,6 +523,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -557,6 +565,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -602,6 +611,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -629,6 +639,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -660,6 +671,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -684,6 +696,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) let exp = sut.on(\.didAppear) { view in @@ -710,10 +723,11 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(.init()), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) - sut.inspection.inspect(after: 0.2) { view in + sut.inspection.inspect(after: 0.5) { view in XCTAssertNotNil(try view.view(NearbyTransitView.self) .find(text: "Error loading data")) } @@ -742,6 +756,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(route52State), location: .constant(CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78)), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) @@ -806,6 +821,7 @@ final class NearbyTransitViewTests: XCTestCase { getNearby: { _, _ in }, state: .constant(.init(loadedLocation: .init(), nearbyByRouteAndStop: .init(data: []))), location: .constant(ViewportProvider.Defaults.center), + isReturningFromBackground: .constant(false), nearbyVM: .init() ) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/endToEnd/EndToEndRepositories.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/endToEnd/EndToEndRepositories.kt index be5cee66e..d706c0f80 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/endToEnd/EndToEndRepositories.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/endToEnd/EndToEndRepositories.kt @@ -133,6 +133,8 @@ fun endToEndModule(): Module { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun connectV2( stopIds: List, onJoin: (ApiResult) -> Unit, @@ -199,6 +201,8 @@ fun endToEndModule(): Module { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { TODO("Not yet implemented") } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/map/RouteFeaturesBuilder.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/map/RouteFeaturesBuilder.kt index 21316db51..bbd96c602 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/map/RouteFeaturesBuilder.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/map/RouteFeaturesBuilder.kt @@ -241,11 +241,7 @@ object RouteFeaturesBuilder { if (targetRouteData.isNotEmpty()) { return departures?.let { - val upcomingRoutePatternIds = - departures.routes - .flatMap { it.allUpcomingTrips() } - .mapNotNull { it.trip.routePatternId } - val targetRoutePatternIds = upcomingRoutePatternIds.toSet() + val targetRoutePatternIds = departures.upcomingPatternIds targetRouteData.map { routeData -> val filteredShapes = routeData.segmentedShapes.filter { diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NearbyStaticData.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NearbyStaticData.kt index dcf4f66be..c173b0024 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NearbyStaticData.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NearbyStaticData.kt @@ -8,6 +8,7 @@ import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse import com.mbta.tid.mbta_app.model.response.ScheduleResponse import com.mbta.tid.mbta_app.utils.resolveParentId import io.github.dellisd.spatialk.geojson.Position +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlinx.datetime.Instant @@ -449,19 +450,22 @@ data class NearbyStaticData(val data: List) { /** * Attaches [schedules] and [predictions] to the route, stop, and routePattern to which they apply. - * Removes non-typical route patterns which are not predicted within 90 minutes of [filterAtTime]. - * Sorts routes by subway first then nearest stop, stops by distance, and headsigns by route pattern - * sort order. + * Removes non-typical route patterns which are not happening either at all or between + * [filterAtTime] and [filterAtTime] + [hideNonTypicalPatternsBeyondNext]. Sorts routes by subway + * first then nearest stop, stops by distance, and headsigns by route pattern sort order. * * Runs static data and predictions through [TemporaryTerminalRewriter]. */ fun NearbyStaticData.withRealtimeInfo( globalData: GlobalResponse?, - sortByDistanceFrom: Position, + sortByDistanceFrom: Position?, schedules: ScheduleResponse?, predictions: PredictionsStreamDataResponse?, alerts: AlertsStreamDataResponse?, filterAtTime: Instant, + showAllPatternsWhileLoading: Boolean, + hideNonTypicalPatternsBeyondNext: Duration?, + filterCancellations: Boolean, pinnedRoutes: Set ): List { val activeRelevantAlerts = @@ -543,11 +547,26 @@ fun NearbyStaticData.withRealtimeInfo( ) .orEmpty() - val cutoffTime = filterAtTime.plus(90.minutes) + val cutoffTime = hideNonTypicalPatternsBeyondNext?.let { filterAtTime + it } val hasSchedulesTodayByPattern = NearbyStaticData.getSchedulesTodayByPattern(schedules) - val upcomingTripsMap: UpcomingTripsMap = - (upcomingTripsByRoutePatternAndStop + upcomingTripsByDirectionAndStop) + val upcomingTripsMap: UpcomingTripsMap? = + (upcomingTripsByRoutePatternAndStop + upcomingTripsByDirectionAndStop).takeUnless { + schedules == null && predictions == null + } + + fun UpcomingTripsMap.maybeFilterCancellations(isSubway: Boolean) = + if (filterCancellations) this.filterCancellations(isSubway) else this + + fun RealtimePatterns.shouldShow(): Boolean { + if (!allDataLoaded && showAllPatternsWhileLoading) return true + val isUpcoming = + when (cutoffTime) { + null -> this.isUpcoming() + else -> this.isUpcomingWithin(filterAtTime, cutoffTime) + } + return (isTypical() || isUpcoming) && !isArrivalOnly() + } fun List.filterEmptyAndSort(): List { return this.filterNot { it.patterns.isEmpty() } @@ -562,14 +581,13 @@ fun NearbyStaticData.withRealtimeInfo( StopsAssociated.WithRoute( transit.route, transit.patternsByStop - .map { + .map { stopPatterns -> PatternsByStop( - it, - upcomingTripsMap.filterCancellations( + stopPatterns, + upcomingTripsMap?.maybeFilterCancellations( transit.route.type.isSubway() ), - filterAtTime, - cutoffTime, + { it.shouldShow() }, activeRelevantAlerts, hasSchedulesTodayByPattern, allDataLoaded @@ -582,14 +600,13 @@ fun NearbyStaticData.withRealtimeInfo( transit.line, transit.routes, transit.patternsByStop - .map { + .map { stopPatterns -> PatternsByStop( - it, - upcomingTripsMap.filterCancellations( + stopPatterns, + upcomingTripsMap?.maybeFilterCancellations( transit.routes.min().type.isSubway() ), - filterAtTime, - cutoffTime, + { it.shouldShow() }, activeRelevantAlerts, hasSchedulesTodayByPattern, allDataLoaded @@ -604,6 +621,29 @@ fun NearbyStaticData.withRealtimeInfo( .sortedWith(PatternSorting.compareStopsAssociated(pinnedRoutes, sortByDistanceFrom)) } +fun NearbyStaticData.withRealtimeInfo( + globalData: GlobalResponse?, + sortByDistanceFrom: Position, + schedules: ScheduleResponse?, + predictions: PredictionsStreamDataResponse?, + alerts: AlertsStreamDataResponse?, + filterAtTime: Instant, + pinnedRoutes: Set +): List { + return this.withRealtimeInfo( + globalData, + sortByDistanceFrom, + schedules, + predictions, + alerts, + filterAtTime, + showAllPatternsWhileLoading = false, + hideNonTypicalPatternsBeyondNext = 90.minutes, + filterCancellations = true, + pinnedRoutes + ) +} + class NearbyStaticDataBuilder { val data = mutableListOf() diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternSorting.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternSorting.kt index d28b66053..e4b6bb165 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternSorting.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternSorting.kt @@ -72,13 +72,17 @@ object PatternSorting { fun compareStopsAssociated( pinnedRoutes: Set, - sortByDistanceFrom: Position + sortByDistanceFrom: Position? ): Comparator = compareBy( { pinnedRouteBucket(it.sortRoute(), pinnedRoutes) }, { patternServiceBucket(it.patternsByStop.first().patterns.first()) }, { subwayBucket(it.sortRoute()) }, - { it.distanceFrom(sortByDistanceFrom) }, + if (sortByDistanceFrom != null) { + { it.distanceFrom(sortByDistanceFrom) } + } else { + { 0 } + }, { it.sortRoute() }, ) } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternsByStop.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternsByStop.kt index a71860f0f..9e56abc7b 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternsByStop.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/PatternsByStop.kt @@ -4,7 +4,6 @@ import com.mbta.tid.mbta_app.model.response.GlobalResponse import io.github.dellisd.spatialk.geojson.Position import io.github.dellisd.spatialk.turf.ExperimentalTurfApi import io.github.dellisd.spatialk.turf.distance -import kotlinx.datetime.Instant /** * @property patterns [RealtimePatterns]s serving the stop grouped by headsign or direction. The @@ -24,8 +23,7 @@ data class PatternsByStop( constructor( staticData: NearbyStaticData.StopPatterns, upcomingTripsMap: UpcomingTripsMap?, - filterTime: Instant, - cutoffTime: Instant, + patternsPredicate: (RealtimePatterns) -> Boolean, alerts: Collection?, hasSchedulesTodayByPattern: Map?, allDataLoaded: Boolean, @@ -62,10 +60,7 @@ data class PatternsByStop( ) } } - .filter { - (it.isTypical() || it.isUpcomingWithin(filterTime, cutoffTime)) && - !it.isArrivalOnly() - } + .filter(patternsPredicate) .sortedWith(PatternSorting.compareRealtimePatterns()), staticData.directions ) 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 index 1ac5412e7..463a33c21 100644 --- 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 @@ -2,9 +2,10 @@ package com.mbta.tid.mbta_app.model import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse import com.mbta.tid.mbta_app.model.response.GlobalResponse +import com.mbta.tid.mbta_app.model.response.NearbyResponse import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse import com.mbta.tid.mbta_app.model.response.ScheduleResponse -import com.mbta.tid.mbta_app.utils.resolveParentId +import com.mbta.tid.mbta_app.model.response.VehiclesStreamDataResponse import kotlinx.datetime.Instant data class StopDetailsDepartures(val routes: List) { @@ -18,76 +19,41 @@ data class StopDetailsDepartures(val routes: List) { filterAtTime: Instant ) : this( global.run { - val loading = schedules == null || predictions == null - val tripMapByHeadsign = tripMapByHeadsign(stops, schedules, predictions, filterAtTime) - val allStopIds = if (patternIdsByStop.containsKey(stop.id)) { - setOf(stop.id) + listOf(stop.id) } else { - stop.childStopIds.toSet() - } - - val patternsByRoute = - allStopIds - .flatMap { patternIdsByStop[it] ?: emptyList() } - .map { patternId -> routePatterns.getValue(patternId) } - .groupBy { routes.getValue(it.routeId) } - - val touchedLines: MutableSet = mutableSetOf() - - val activeRelevantAlerts = - alerts?.alerts?.values?.filter { - it.isActive(filterAtTime) && it.significance >= AlertSignificance.Minor + stop.childStopIds.filter { global.stops.containsKey(it) } } - val hasSchedulesTodayByPattern = NearbyStaticData.getSchedulesTodayByPattern(schedules) - patternsByRoute - .mapNotNull { (route, routePatterns) -> - if (touchedLines.contains(route.lineId)) { - return@mapNotNull null - } else if (NearbyStaticData.groupedLines.contains(route.lineId)) { - val line = global.lines[route.lineId] ?: return@mapNotNull null - touchedLines.add(line.id) + val staticData = NearbyStaticData(global, NearbyResponse(allStopIds)) - return@mapNotNull patternsByStopForLine( - stop, - line, - patternsByRoute, - tripMapByHeadsignOrDirection( - stops, - tripMapByHeadsign, - schedules, - predictions, - filterAtTime - ), - allStopIds, - loading, - global, - activeRelevantAlerts, - hasSchedulesTodayByPattern - ) - } - - return@mapNotNull patternsByStopForRoute( - stop, - route, - routePatterns, - tripMapByHeadsign, - allStopIds, - loading, - global, - activeRelevantAlerts, - hasSchedulesTodayByPattern - ) - } - .filterNot { it.patterns.isEmpty() } - .sortedWith( - PatternSorting.comparePatternsByStop(pinnedRoutes, sortByDistanceFrom = null) + staticData + .withRealtimeInfo( + global, + null, + schedules, + predictions, + alerts, + filterAtTime, + showAllPatternsWhileLoading = true, + hideNonTypicalPatternsBeyondNext = null, + filterCancellations = false, + pinnedRoutes ) + .flatMap { it.patternsByStop } } ) + val allUpcomingTrips = routes.flatMap { it.allUpcomingTrips() } + + val upcomingPatternIds = allUpcomingTrips.mapNotNull { it.trip.routePatternId }.toSet() + + fun filterVehiclesByUpcoming(vehicles: VehiclesStreamDataResponse): Map { + val routeIds = allUpcomingTrips.map { it.trip.routeId }.toSet() + return vehicles.vehicles.filter { routeIds.contains(it.value.routeId) } + } + fun autoFilter(): StopDetailsFilter? { if (routes.size != 1) { return null @@ -100,212 +66,4 @@ data class StopDetailsDepartures(val routes: List) { val direction = directions.first() return StopDetailsFilter(route.routeIdentifier, direction) } - - companion object { - - private fun tripMapByHeadsign( - stops: Map, - schedules: ScheduleResponse?, - predictions: PredictionsStreamDataResponse?, - filterAtTime: Instant - ): Map>? { - return UpcomingTrip.tripsMappedBy( - stops, - schedules, - predictions, - scheduleKey = { schedule, scheduleData -> - val trip = scheduleData.trips.getValue(schedule.tripId) - RealtimePatterns.UpcomingTripKey.ByRoutePattern( - schedule.routeId, - trip.routePatternId, - stops.resolveParentId(schedule.stopId) - ) - }, - predictionKey = { prediction, streamData -> - val trip = streamData.trips.getValue(prediction.tripId) - RealtimePatterns.UpcomingTripKey.ByRoutePattern( - prediction.routeId, - trip.routePatternId, - stops.resolveParentId(prediction.stopId) - ) - }, - filterAtTime - ) - } - - private fun tripMapByHeadsignOrDirection( - stops: Map, - tripMapByRoutePattern: - Map>?, - schedules: ScheduleResponse?, - predictions: PredictionsStreamDataResponse?, - filterAtTime: Instant - ): Map>? { - val tripMapByDirection = - UpcomingTrip.tripsMappedBy( - stops, - schedules, - predictions, - scheduleKey = { schedule, scheduleData -> - val trip = scheduleData.trips.getValue(schedule.tripId) - RealtimePatterns.UpcomingTripKey.ByDirection( - schedule.routeId, - trip.directionId, - stops.resolveParentId(schedule.stopId) - ) - }, - predictionKey = { prediction, streamData -> - val trip = streamData.trips.getValue(prediction.tripId) - RealtimePatterns.UpcomingTripKey.ByDirection( - prediction.routeId, - trip.directionId, - stops.resolveParentId(prediction.stopId) - ) - }, - filterAtTime - ) - - return if (tripMapByRoutePattern != null || tripMapByDirection != null) { - (tripMapByRoutePattern ?: emptyMap()) + (tripMapByDirection ?: emptyMap()) - } else { - null - } - } - - private fun patternsByStopForRoute( - stop: Stop, - route: Route, - routePatterns: List, - tripMap: Map>?, - allStopIds: Set, - loading: Boolean, - global: GlobalResponse, - alerts: Collection?, - hasSchedulesTodayByPattern: Map?, - ): PatternsByStop { - global.run { - val allDataLoaded = !loading - val patternsByHeadsign = - routePatterns.groupBy { trips.getValue(it.representativeTripId).headsign } - return PatternsByStop( - listOf(route), - null, - stop, - patternsByHeadsign - .map { (headsign, patterns) -> - val stopIdsOnPatterns = - NearbyStaticData.filterStopsByPatterns(patterns, global, allStopIds) - val upcomingTrips = - if (tripMap != null) { - patterns - .mapNotNull { pattern -> - tripMap[ - RealtimePatterns.UpcomingTripKey.ByRoutePattern( - route.id, - pattern.id, - stop.id - )] - } - .flatten() - .sorted() - } else { - null - } - ?.filterCancellations(route.type.isSubway()) - RealtimePatterns.ByHeadsign( - route, - headsign, - null, - patterns, - upcomingTrips, - alerts?.let { - RealtimePatterns.applicableAlerts( - routes = listOf(route), - stopIds = stopIdsOnPatterns, - alerts = alerts - ) - }, - RealtimePatterns.hasSchedulesToday( - hasSchedulesTodayByPattern, - patterns - ), - allDataLoaded - ) - } - .filter { - loading || ((it.isTypical() || it.isUpcoming()) && !it.isArrivalOnly()) - } - .sortedWith(PatternSorting.compareRealtimePatterns()), - Direction.getDirections(global, stop, route, routePatterns) - ) - } - } - - private fun patternsByStopForLine( - stop: Stop, - line: Line, - patternsByRoute: Map>, - tripMap: Map>?, - allStopIds: Set, - loading: Boolean, - global: GlobalResponse, - alerts: Collection?, - hasSchedulesTodayByPattern: Map?, - ): PatternsByStop { - global.run { - val allDataLoaded = !loading - val groupedPatternsByRoute = patternsByRoute.filter { it.key.lineId == line.id } - - val staticPatterns = - NearbyStaticData.buildStopPatternsForLine( - stop, - groupedPatternsByRoute, - line, - allStopIds, - global - ) - - val realtimePatterns = - staticPatterns.patterns - .map { - when (it) { - is NearbyStaticData.StaticPatterns.ByHeadsign -> - RealtimePatterns.ByHeadsign( - it, - tripMap, - stop.id, - alerts, - hasSchedulesTodayByPattern, - allDataLoaded - ) - is NearbyStaticData.StaticPatterns.ByDirection -> - RealtimePatterns.ByDirection( - it, - tripMap, - stop.id, - alerts, - hasSchedulesTodayByPattern, - allDataLoaded - ) - } - } - .filter { - loading || ((it.isTypical() || it.isUpcoming()) && !it.isArrivalOnly()) - } - .sortedWith(PatternSorting.compareRealtimePatterns()) - - return PatternsByStop( - groupedPatternsByRoute.map { it.key }, - line, - stop, - realtimePatterns, - Direction.getDirectionsForLine( - global, - stop, - realtimePatterns.flatMap { it.patterns } - ) - ) - } - } - } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannel.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannel.kt index abd9a128c..b3fb6f875 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannel.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannel.kt @@ -8,6 +8,9 @@ object VehiclesOnRouteChannel { val newDataEvent = "stream_data" + fun topic(routeIds: List, directionId: Int) = + "vehicles:routes:${routeIds.joinToString(",")}:${directionId}" + fun joinPayload(routeId: String, directionId: Int): Map { return mapOf("route_id" to routeId, "direction_id" to directionId) } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt index 77b53bdca..a251505da 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt @@ -33,7 +33,7 @@ protected constructor(initialState: ErrorBannerState? = null) { action: () -> Unit ) { predictionsStale = - if (predictionQuantity > 0 && Clock.System.now() - predictionsLastUpdated > 1.minutes) { + if (predictionQuantity > 0 && Clock.System.now() - predictionsLastUpdated > 2.minutes) { ErrorBannerState.StalePredictions(predictionsLastUpdated, action) } else { null diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepository.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepository.kt index 5ae0f8a11..013fddb27 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepository.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepository.kt @@ -10,6 +10,7 @@ import com.mbta.tid.mbta_app.network.PhoenixMessage import com.mbta.tid.mbta_app.network.PhoenixPushStatus import com.mbta.tid.mbta_app.network.PhoenixSocket import com.mbta.tid.mbta_app.phoenix.PredictionsForStopsChannel +import kotlin.time.Duration.Companion.minutes import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.koin.core.component.KoinComponent @@ -28,6 +29,8 @@ interface IPredictionsRepository { var lastUpdated: Instant? + fun shouldForgetPredictions(predictionCount: Int): Boolean + fun disconnect() } @@ -38,6 +41,10 @@ class PredictionsRepository(private val socket: PhoenixSocket) : override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = + (Clock.System.now() - (lastUpdated ?: Instant.DISTANT_FUTURE)) > 10.minutes && + predictionCount > 0 + override fun connect( stopIds: List, onReceive: (ApiResult) -> Unit @@ -221,6 +228,8 @@ class MockPredictionsRepository( override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { onDisconnect() } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/TripPredictionsRepository.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/TripPredictionsRepository.kt index 90cce9842..dc06582e9 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/TripPredictionsRepository.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/TripPredictionsRepository.kt @@ -8,6 +8,7 @@ import com.mbta.tid.mbta_app.network.PhoenixMessage import com.mbta.tid.mbta_app.network.PhoenixPushStatus import com.mbta.tid.mbta_app.network.PhoenixSocket import com.mbta.tid.mbta_app.phoenix.PredictionsForStopsChannel +import kotlin.time.Duration.Companion.minutes import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.koin.core.component.KoinComponent @@ -17,6 +18,8 @@ interface ITripPredictionsRepository { var lastUpdated: Instant? + fun shouldForgetPredictions(predictionCount: Int): Boolean + fun disconnect() } @@ -27,6 +30,10 @@ class TripPredictionsRepository(private val socket: PhoenixSocket) : override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = + (Clock.System.now() - (lastUpdated ?: Instant.DISTANT_FUTURE)) > 10.minutes && + predictionCount > 0 + override fun connect( tripId: String, onReceive: (ApiResult) -> Unit @@ -89,6 +96,8 @@ class MockTripPredictionsRepository : ITripPredictionsRepository { override var lastUpdated: Instant? = null + override fun shouldForgetPredictions(predictionCount: Int) = false + override fun disconnect() { /* no-op */ } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepository.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepository.kt index c3ac30fd0..67ee2efc5 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepository.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepository.kt @@ -2,6 +2,7 @@ package com.mbta.tid.mbta_app.repositories import com.mbta.tid.mbta_app.model.SocketError import com.mbta.tid.mbta_app.model.Vehicle +import com.mbta.tid.mbta_app.model.greenRoutes import com.mbta.tid.mbta_app.model.response.ApiResult import com.mbta.tid.mbta_app.model.response.VehiclesStreamDataResponse import com.mbta.tid.mbta_app.network.PhoenixChannel @@ -29,8 +30,16 @@ class VehiclesRepository(private val socket: PhoenixSocket) : IVehiclesRepositor directionId: Int, onReceive: (ApiResult) -> Unit ) { - val joinPayload = VehiclesOnRouteChannel.joinPayload(routeId, directionId) - channel = socket.getChannel(VehiclesOnRouteChannel.topic, joinPayload) + val topic = + VehiclesOnRouteChannel.topic( + if (routeId == "line-Green") { + greenRoutes.toList() + } else { + listOf(routeId) + }, + directionId + ) + channel = socket.getChannel(topic, emptyMap()) channel?.onEvent(VehiclesOnRouteChannel.newDataEvent) { message -> handleNewDataMessage(message, onReceive) diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt index 7ef3784ed..956ac7c07 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt @@ -1221,7 +1221,7 @@ class NearbyResponseTest { "Typical Out", null, listOf(typicalOutbound), - emptyList(), + null, allDataLoaded = false ), RealtimePatterns.ByHeadsign( @@ -1229,7 +1229,7 @@ class NearbyResponseTest { "Typical In", null, listOf(typicalInbound), - emptyList(), + null, allDataLoaded = false ), ) @@ -1249,6 +1249,187 @@ class NearbyResponseTest { ) } + @Test + fun `withRealtimeInfo hideNonTypicalPatternsBeyondNext when null doesn't filter`() { + val objects = ObjectCollectionBuilder() + + val stop1 = objects.stop() + + val route1 = objects.route() + + // should be included because typical and has prediction + val typicalOutbound = + objects.routePattern(route1) { + directionId = 0 + sortOrder = 1 + typicality = RoutePattern.Typicality.Typical + representativeTrip { headsign = "Typical Out" } + } + + // should be included because hideNonTypicalPatternsBeyondNext is null + val deviationInbound = + objects.routePattern(route1) { + directionId = 1 + sortOrder = 4 + typicality = RoutePattern.Typicality.Deviation + representativeTrip { headsign = "Deviation In" } + } + + val staticData = + NearbyStaticData.build { + route(route1) { + stop(stop1) { + headsign("Typical Out", listOf(typicalOutbound)) + headsign("Deviation In", listOf(deviationInbound)) + } + } + } + + val time = Instant.parse("2024-02-22T12:08:19-05:00") + + val typicalOutboundPrediction = + objects.prediction { + departureTime = time + routeId = route1.id + stopId = stop1.id + tripId = typicalOutbound.representativeTripId + } + + val deviationInboundPrediction = + objects.prediction { + departureTime = time + 95.minutes + routeId = route1.id + stopId = stop1.id + tripId = deviationInbound.representativeTripId + } + + assertEquals( + listOf( + StopsAssociated.WithRoute( + route1, + listOf( + PatternsByStop( + route1, + stop1, + listOf( + RealtimePatterns.ByHeadsign( + route1, + "Typical Out", + null, + listOf(typicalOutbound), + listOf(objects.upcomingTrip(typicalOutboundPrediction)), + allDataLoaded = false + ), + RealtimePatterns.ByHeadsign( + route1, + "Deviation In", + null, + listOf(deviationInbound), + listOf(objects.upcomingTrip(deviationInboundPrediction)), + allDataLoaded = false + ), + ) + ) + ) + ) + ), + staticData.withRealtimeInfo( + globalData = GlobalResponse(objects), + sortByDistanceFrom = stop1.position, + schedules = null, + predictions = PredictionsStreamDataResponse(objects), + alerts = null, + filterAtTime = time, + showAllPatternsWhileLoading = false, + hideNonTypicalPatternsBeyondNext = null, + filterCancellations = false, + pinnedRoutes = setOf(), + ) + ) + } + + @Test + fun `withRealtimeInfo includes cancellations when filterCancellations false`() { + val objects = ObjectCollectionBuilder() + val stop1 = objects.stop() + val route1 = objects.route() + + // should be included because typical and has cancelled prediction + val typicalOutbound = + objects.routePattern(route1) { + directionId = 0 + sortOrder = 1 + typicality = RoutePattern.Typicality.Typical + representativeTrip { headsign = "Typical Out" } + } + + val staticData = + NearbyStaticData.build { + route(route1) { stop(stop1) { headsign("Typical Out", listOf(typicalOutbound)) } } + } + + val time = Instant.parse("2024-02-22T12:08:19-05:00") + + val typicalOutboundSchedule = + objects.schedule { + routeId = route1.id + tripId = typicalOutbound.representativeTripId + stopId = stop1.id + arrivalTime = time + departureTime = time + } + + val typicalOutboundPrediction = + objects.prediction { + departureTime = null + routeId = route1.id + stopId = stop1.id + tripId = typicalOutbound.representativeTripId + scheduleRelationship = Prediction.ScheduleRelationship.Cancelled + } + + assertEquals( + listOf( + StopsAssociated.WithRoute( + route1, + listOf( + PatternsByStop( + route1, + stop1, + listOf( + RealtimePatterns.ByHeadsign( + route1, + "Typical Out", + null, + listOf(typicalOutbound), + listOf( + objects.upcomingTrip( + typicalOutboundSchedule, + typicalOutboundPrediction + ) + ), + allDataLoaded = true + ), + ) + ) + ) + ) + ), + staticData.withRealtimeInfo( + globalData = GlobalResponse(objects), + sortByDistanceFrom = stop1.position, + schedules = ScheduleResponse(objects), + predictions = PredictionsStreamDataResponse(objects), + alerts = null, + filterAtTime = time, + showAllPatternsWhileLoading = false, + hideNonTypicalPatternsBeyondNext = null, + filterCancellations = false, + pinnedRoutes = setOf(), + ) + ) + } + @Test fun `withRealtimeInfo sorts subway first then by distance`() { val objects = ObjectCollectionBuilder() 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 index 3b3ceccc8..177180476 100644 --- 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 @@ -4,6 +4,7 @@ import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse 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 com.mbta.tid.mbta_app.model.response.VehiclesStreamDataResponse import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.hours @@ -228,6 +229,30 @@ class StopDetailsDeparturesTest { val directionWest = Direction("West", "Kenmore & West", 0) val directionEast = Direction("East", "Park St & North", 1) + val departures = + StopDetailsDepartures( + stop, + GlobalResponse( + objects, + mapOf( + stop.id to + listOf( + routePatternB1.id, + routePatternB2.id, + routePatternC1.id, + routePatternC2.id, + routePatternE1.id, + routePatternE2.id + ) + ) + ), + ScheduleResponse(objects), + PredictionsStreamDataResponse(objects), + null, + setOf(), + filterAtTime = time, + ) + assertEquals( StopDetailsDepartures( listOf( @@ -265,10 +290,119 @@ class StopDetailsDeparturesTest { ) ), ), - listOf(Direction("West", null, 0), directionEast) + listOf(directionWest, directionEast) ) ) ), + departures + ) + + assertEquals( + setOf( + routePatternB1.id, + routePatternC1.id, + routePatternE1.id, + routePatternB2.id, + routePatternC2.id, + routePatternE2.id + ), + departures.upcomingPatternIds + ) + } + + @Test + fun `StopDetailsDepartures filters vehicles by relevant routes`() { + val objects = ObjectCollectionBuilder() + + val stop = objects.stop() + objects.line { id = "line-Green" } + val routeB = + objects.route { + id = "B" + sortOrder = 1 + lineId = "line-Green" + directionNames = listOf("West", "East") + directionDestinations = listOf("Kenmore & West", "Park St & North") + } + val routePatternB = + objects.routePattern(routeB) { + representativeTrip { headsign = "B" } + directionId = 0 + typicality = RoutePattern.Typicality.Typical + } + val tripB = objects.trip(routePatternB) + + val routeC = + objects.route { + id = "C" + sortOrder = 2 + lineId = "line-Green" + directionNames = listOf("West", "East") + directionDestinations = listOf("Kenmore & West", "Park St & North") + } + val routePatternC = + objects.routePattern(routeC) { + representativeTrip { headsign = "C" } + directionId = 0 + typicality = RoutePattern.Typicality.Typical + } + val tripC = objects.trip(routePatternC) + + val routeD = + objects.route { + id = "D" + sortOrder = 3 + lineId = "line-Green" + directionNames = listOf("West", "East") + directionDestinations = listOf("Riverside", "Park St & North") + } + + val routeE = + objects.route { + id = "E" + sortOrder = 3 + lineId = "line-Green" + directionNames = listOf("West", "East") + directionDestinations = listOf("Heath Street", "Park St & North") + } + val routePatternE = + objects.routePattern(routeE) { + representativeTrip { headsign = "Heath Street" } + directionId = 0 + typicality = RoutePattern.Typicality.Typical + id = "test-hs" + } + val tripE = objects.trip(routePatternE) + + val time = Instant.parse("2024-03-18T10:41:13-04:00") + + val schedB = + objects.schedule { + trip = tripB + stopId = stop.id + stopSequence = 90 + departureTime = time + 1.minutes + } + val schedC = + objects.schedule { + trip = tripC + stopId = stop.id + stopSequence = 90 + departureTime = time + 2.minutes + } + val schedE = + objects.schedule { + trip = tripE + stopId = stop.id + stopSequence = 90 + departureTime = time + 3.minutes + } + + objects.prediction(schedB) { departureTime = time + 1.5.minutes } + objects.prediction(schedC) { departureTime = time + 2.3.minutes } + objects.prediction(schedE) { departureTime = time + 2.3.minutes } + + val departures = StopDetailsDepartures( stop, GlobalResponse( @@ -276,12 +410,9 @@ class StopDetailsDeparturesTest { mapOf( stop.id to listOf( - routePatternB1.id, - routePatternB2.id, - routePatternC1.id, - routePatternC2.id, - routePatternE1.id, - routePatternE2.id + routePatternB.id, + routePatternC.id, + routePatternE.id, ) ) ), @@ -291,6 +422,42 @@ class StopDetailsDeparturesTest { setOf(), filterAtTime = time, ) + val vehicleB = + objects.vehicle { + routeId = routeB.id + currentStatus = Vehicle.CurrentStatus.InTransitTo + } + val vehicleC = + objects.vehicle { + routeId = routeC.id + currentStatus = Vehicle.CurrentStatus.InTransitTo + } + val vehicleD = + objects.vehicle { + routeId = routeD.id + currentStatus = Vehicle.CurrentStatus.InTransitTo + } + val vehicleE = + objects.vehicle { + routeId = routeE.id + currentStatus = Vehicle.CurrentStatus.InTransitTo + } + val vehicleResponse = + VehiclesStreamDataResponse( + mapOf( + vehicleB.id to vehicleB, + vehicleC.id to vehicleC, + vehicleD.id to vehicleD, + vehicleE.id to vehicleE, + ) + ) + assertEquals( + mapOf( + vehicleB.id to vehicleB, + vehicleC.id to vehicleC, + vehicleE.id to vehicleE, + ), + departures.filterVehiclesByUpcoming(vehicleResponse) ) } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TemporaryTerminalTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TemporaryTerminalTest.kt index fb3eea5a0..a66c98a40 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TemporaryTerminalTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TemporaryTerminalTest.kt @@ -309,35 +309,36 @@ class TemporaryTerminalTest { @Test fun `shows only temporary terminals when outside shuttle`() { - assertEquals( + val expected = expected( - harvard.station, - RealtimePatterns.ByHeadsign( - red, - "Kendall/MIT", - null, - listOf(redAlewifeBraintree, redAlewifeAshmont, redAlewifeKendall), - listOf( - UpcomingTrip(tripAlewifeKendall, scheduleAlewifeKendall), - UpcomingTrip(tripAlewifeBraintree1, predictionAlewifeBraintree1), - UpcomingTrip(tripAlewifeBraintree2, predictionAlewifeBraintree2), - UpcomingTrip(tripAlewifeBraintree3, predictionAlewifeBraintree3) - ), - emptyList() + harvard.station, + RealtimePatterns.ByHeadsign( + red, + "Kendall/MIT", + null, + listOf(redAlewifeBraintree, redAlewifeAshmont, redAlewifeKendall), + listOf( + UpcomingTrip(tripAlewifeKendall, scheduleAlewifeKendall), + UpcomingTrip(tripAlewifeBraintree1, predictionAlewifeBraintree1), + UpcomingTrip(tripAlewifeBraintree2, predictionAlewifeBraintree2), + UpcomingTrip(tripAlewifeBraintree3, predictionAlewifeBraintree3) + ), + emptyList() + ), + RealtimePatterns.ByHeadsign( + red, + "Alewife", + null, + listOf(redBraintreeAlewife, redAshmontAlewife, redKendallAlewife), + listOf( + UpcomingTrip(tripBraintreeAlewife1, predictionBraintreeAlewife1), + UpcomingTrip(tripKendallAlewife, scheduleKendallAlewife) ), - RealtimePatterns.ByHeadsign( - red, - "Alewife", - null, - listOf(redBraintreeAlewife, redAshmontAlewife, redKendallAlewife), - listOf( - UpcomingTrip(tripBraintreeAlewife1, predictionBraintreeAlewife1), - UpcomingTrip(tripKendallAlewife, scheduleKendallAlewife) - ), - emptyList() - ) + emptyList() ) - .condensed(), + ) + assertEquals( + expected.condensed(), NearbyStaticData(globalData, nearbyOutsideShuttle) .withRealtimeInfo( globalData, @@ -350,39 +351,54 @@ class TemporaryTerminalTest { ) .condensed() ) + assertEquals( + expected.condensed(), + StopDetailsDepartures( + harvard.station, + globalData, + schedules, + predictions, + alerts, + emptySet(), + now + ) + .asNearby() + .condensed() + ) } @Test fun `shows only regular terminals when inside shuttle`() { - assertEquals( + val expected = expected( - parkStreet.station, - RealtimePatterns.ByHeadsign( - red, - "Braintree", - null, - listOf(redAlewifeBraintree), - emptyList(), - listOf(alert) - ), - RealtimePatterns.ByHeadsign( - red, - "Ashmont", - null, - listOf(redAlewifeAshmont), - emptyList(), - listOf(alert) - ), - RealtimePatterns.ByHeadsign( - red, - "Alewife", - null, - listOf(redBraintreeAlewife, redAshmontAlewife), - emptyList(), - listOf(alert) - ) + parkStreet.station, + RealtimePatterns.ByHeadsign( + red, + "Braintree", + null, + listOf(redAlewifeBraintree), + emptyList(), + listOf(alert) + ), + RealtimePatterns.ByHeadsign( + red, + "Ashmont", + null, + listOf(redAlewifeAshmont), + emptyList(), + listOf(alert) + ), + RealtimePatterns.ByHeadsign( + red, + "Alewife", + null, + listOf(redBraintreeAlewife, redAshmontAlewife), + emptyList(), + listOf(alert) ) - .condensed(), + ) + assertEquals( + expected.condensed(), NearbyStaticData(globalData, nearbyInsideShuttle) .withRealtimeInfo( globalData, @@ -395,40 +411,55 @@ class TemporaryTerminalTest { ) .condensed() ) + assertEquals( + expected.condensed(), + StopDetailsDepartures( + parkStreet.station, + globalData, + schedules, + predictions, + alerts, + emptySet(), + now + ) + .asNearby() + .condensed() + ) } @Test fun `shows correct set of terminals when at boundary of shuttle`() { - assertEquals( + val expected = expected( - jfkUmass.station, - RealtimePatterns.ByHeadsign( - red, - "Braintree", - null, - listOf(redAlewifeBraintree, redJfkBraintree), - listOf(UpcomingTrip(tripJfkBraintree, scheduleJfkBraintree)), - emptyList() - ), - RealtimePatterns.ByHeadsign( - red, - "Ashmont", - null, - listOf(redAlewifeAshmont, redJfkAshmont), - listOf(UpcomingTrip(tripJfkAshmont, scheduleJfkAshmont)), - emptyList() - ), - RealtimePatterns.ByHeadsign( - red, - "Alewife", - null, - listOf(redBraintreeAlewife, redAshmontAlewife), - emptyList(), - listOf(alert) - ), - // JFK/UMass filtered out because arrival only - ) - .condensed(), + jfkUmass.station, + RealtimePatterns.ByHeadsign( + red, + "Braintree", + null, + listOf(redAlewifeBraintree, redJfkBraintree), + listOf(UpcomingTrip(tripJfkBraintree, scheduleJfkBraintree)), + emptyList() + ), + RealtimePatterns.ByHeadsign( + red, + "Ashmont", + null, + listOf(redAlewifeAshmont, redJfkAshmont), + listOf(UpcomingTrip(tripJfkAshmont, scheduleJfkAshmont)), + emptyList() + ), + RealtimePatterns.ByHeadsign( + red, + "Alewife", + null, + listOf(redBraintreeAlewife, redAshmontAlewife), + emptyList(), + listOf(alert) + ), + // JFK/UMass filtered out because arrival only + ) + assertEquals( + expected.condensed(), NearbyStaticData(globalData, nearbyAtShuttleEdge) .withRealtimeInfo( globalData, @@ -441,8 +472,26 @@ class TemporaryTerminalTest { ) .condensed() ) + assertEquals( + expected.condensed(), + StopDetailsDepartures( + jfkUmass.station, + globalData, + schedules, + predictions, + alerts, + emptySet(), + now + ) + .asNearby() + .condensed() + ) } + // for easier assertions about stop details + fun StopDetailsDepartures.asNearby(): List = + listOf(StopsAssociated.WithRoute(red, this.routes)) + // for more legible diffs fun List.condensed(): String { fun Instant.condensed() = toLocalDateTime(TimeZone.of("America/New_York")).time.toString() diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannelTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannelTest.kt index bb757a700..ea514e29a 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannelTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/phoenix/VehiclesOnRouteChannelTest.kt @@ -59,4 +59,13 @@ class VehiclesOnRouteChannelTest { parsed ) } + + @Test + fun testTopicInterpolation() { + val topic1 = VehiclesOnRouteChannel.topic(listOf("Red"), 0) + val topic2 = VehiclesOnRouteChannel.topic(listOf("Green-B", "Green-C", "Green-D"), 1) + + assertEquals("vehicles:routes:Red:0", topic1) + assertEquals("vehicles:routes:Green-B,Green-C,Green-D:1", topic2) + } } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt index 6d843c2e4..84c286daf 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt @@ -25,7 +25,7 @@ class ErrorBannerStateRepositoryTest { fun `updates if predictions are stale`() = runBlocking { val repo = ErrorBannerStateRepository() - val lastUpdated = Clock.System.now() - 2.minutes + val lastUpdated = Clock.System.now() - 3.minutes val action = {} repo.checkPredictionsStale(lastUpdated, 1, action) @@ -37,7 +37,7 @@ class ErrorBannerStateRepositoryTest { fun `data errors override stale predictions`() { val repo = ErrorBannerStateRepository() - repo.checkPredictionsStale(Clock.System.now() - 2.minutes, 1) {} + repo.checkPredictionsStale(Clock.System.now() - 3.minutes, 1) {} repo.setDataError("global") {} @@ -67,7 +67,7 @@ class ErrorBannerStateRepositoryTest { fun `clears if predictions stop being stale`() = runBlocking { val repo = ErrorBannerStateRepository() - repo.checkPredictionsStale(Clock.System.now() - 2.minutes, 1) {} + repo.checkPredictionsStale(Clock.System.now() - 3.minutes, 1) {} assertNotNull(repo.state.value) @@ -102,7 +102,7 @@ class ErrorBannerStateRepositoryTest { assertEquals(null, channel.receive()) - val lastUpdated = Clock.System.now() - 2.minutes + val lastUpdated = Clock.System.now() - 3.minutes val action = {} repo.checkPredictionsStale(lastUpdated, 1, action) diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepositoryTests.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepositoryTests.kt index ac2beab9d..398055b75 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepositoryTests.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/PredictionsRepositoryTests.kt @@ -18,9 +18,14 @@ import dev.mokkery.mock import dev.mokkery.verify import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.koin.test.KoinTest class PredictionsRepositoryTests : KoinTest { @@ -416,4 +421,33 @@ class PredictionsRepositoryTests : KoinTest { } ) } + + @Test + fun `shouldForgetPredictions false when never updated`() { + val predictionsRepo = PredictionsRepository(mock(MockMode.autofill)) + predictionsRepo.lastUpdated = null + // there will not, in practice, be ten predictions and no last updated time + assertFalse(predictionsRepo.shouldForgetPredictions(10)) + } + + @Test + fun `shouldForgetPredictions false when no predictions`() { + val predictionsRepo = PredictionsRepository(mock(MockMode.autofill)) + predictionsRepo.lastUpdated = Instant.DISTANT_PAST + assertFalse(predictionsRepo.shouldForgetPredictions(0)) + } + + @Test + fun `shouldForgetPredictions false when within ten minutes`() { + val predictionsRepo = PredictionsRepository(mock(MockMode.autofill)) + predictionsRepo.lastUpdated = Clock.System.now() - 9.9.minutes + assertFalse(predictionsRepo.shouldForgetPredictions(10)) + } + + @Test + fun `shouldForgetPredictions true when old and nonempty`() { + val predictionsRepo = PredictionsRepository(mock(MockMode.autofill)) + predictionsRepo.lastUpdated = Clock.System.now() - 10.1.minutes + assertTrue(predictionsRepo.shouldForgetPredictions(10)) + } } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepositoryTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepositoryTest.kt index f2fe61572..e32d089fa 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/VehiclesRepositoryTest.kt @@ -9,7 +9,7 @@ import com.mbta.tid.mbta_app.network.PhoenixMessage import com.mbta.tid.mbta_app.network.PhoenixPush import com.mbta.tid.mbta_app.network.PhoenixPushStatus import com.mbta.tid.mbta_app.network.PhoenixSocket -import com.mbta.tid.mbta_app.phoenix.PredictionsForStopsChannel +import com.mbta.tid.mbta_app.phoenix.VehiclesOnRouteChannel import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every @@ -44,7 +44,7 @@ class VehiclesRepositoryTest : KoinTest { val vehiclesRepo = VehiclesRepository(socket) every { socket.getChannel(any(), any()) } returns mock(MockMode.autofill) vehiclesRepo.channel = - socket.getChannel(topic = PredictionsForStopsChannel.topic, params = emptyMap()) + socket.getChannel(topic = VehiclesOnRouteChannel.topic, params = emptyMap()) assertNotNull(vehiclesRepo.channel) vehiclesRepo.disconnect()