diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/SheetRoutesTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/SheetRoutesTest.kt new file mode 100644 index 000000000..4dcafbb2c --- /dev/null +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/SheetRoutesTest.kt @@ -0,0 +1,38 @@ +package com.mbta.tid.mbta_app.android + +import com.mbta.tid.mbta_app.model.StopDetailsFilter +import junit.framework.TestCase.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class SheetRouteTest { + @Test + fun testPageChangedNearbyToStopDetails() { + assertTrue( + SheetRoutes.pageChanged( + SheetRoutes.NearbyTransit, + SheetRoutes.StopDetails("a", null, null) + ) + ) + } + + @Test + fun testPageChangedWhenStopDetailsDifferentStops() { + assertTrue( + SheetRoutes.pageChanged( + SheetRoutes.StopDetails("a", null, null), + SheetRoutes.StopDetails("b", null, null) + ) + ) + } + + @Test + fun testPageNotChangedWhenStopDetailsSameStopDifferentFilters() { + assertFalse( + SheetRoutes.pageChanged( + SheetRoutes.StopDetails("a", null, null), + SheetRoutes.StopDetails("a", StopDetailsFilter("route1", 1), null) + ) + ) + } +} 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 efd2ce119..4796ba5a3 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 @@ -28,6 +28,7 @@ import com.mbta.tid.mbta_app.android.location.ViewportProvider import com.mbta.tid.mbta_app.android.map.IMapViewModel import com.mbta.tid.mbta_app.android.pages.NearbyTransit import com.mbta.tid.mbta_app.android.pages.NearbyTransitPage +import com.mbta.tid.mbta_app.android.stopDetails.StopDetailsViewModel import com.mbta.tid.mbta_app.android.util.LocalActivity import com.mbta.tid.mbta_app.android.util.LocalLocationClient import com.mbta.tid.mbta_app.map.RouteLineData @@ -74,8 +75,8 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.junit.Rule import org.junit.Test -import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.compose.KoinContext +import org.koin.core.module.dsl.* import org.koin.dsl.koinApplication import org.koin.dsl.module import org.koin.test.KoinTest @@ -271,6 +272,7 @@ class NearbyTransitPageTest : KoinTest { single { MockVehiclesRepository() } single { MockSearchResultRepository() } viewModelOf(::NearbyTransitViewModel) + viewModelOf(::StopDetailsViewModel) single { MockVisitHistoryRepository() } single { VisitHistoryUsecase(get()) } } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModelTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModelTest.kt new file mode 100644 index 000000000..2a3f4932a --- /dev/null +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModelTest.kt @@ -0,0 +1,422 @@ +package com.mbta.tid.mbta_app.android.stopDetails + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner +import com.mbta.tid.mbta_app.model.ObjectCollectionBuilder +import com.mbta.tid.mbta_app.model.PatternsByStop +import com.mbta.tid.mbta_app.model.StopDetailsDepartures +import com.mbta.tid.mbta_app.model.Vehicle +import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse +import com.mbta.tid.mbta_app.model.response.ApiResult +import com.mbta.tid.mbta_app.model.response.GlobalResponse +import com.mbta.tid.mbta_app.model.response.PredictionsByStopJoinResponse +import com.mbta.tid.mbta_app.model.response.ScheduleResponse +import com.mbta.tid.mbta_app.repositories.MockErrorBannerStateRepository +import com.mbta.tid.mbta_app.repositories.MockPredictionsRepository +import com.mbta.tid.mbta_app.repositories.MockScheduleRepository +import junit.framework.TestCase.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import org.junit.Rule +import org.junit.Test + +class StopDetailsViewModelTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testLoadStopDetailsLoadsSchedulesAndPredictions() = runTest { + val objects = ObjectCollectionBuilder() + objects.prediction {} + objects.trip {} + objects.vehicle { currentStatus = Vehicle.CurrentStatus.InTransitTo } + objects.schedule {} + objects.trip {} + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)) + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { LaunchedEffect(Unit) { viewModel.loadStopDetails("stop") } } + + composeTestRule.waitUntil { viewModel.stopData?.value?.predictionsByStop != null } + + assertEquals("stop", viewModel.stopData.value?.stopId) + assertNotNull(viewModel.stopData?.value?.predictionsByStop) + assertNotNull(viewModel.stopData?.value?.schedules != null) + } + + @Test + fun testRejoinStopPredictionsWhenStopDataSet() = runTest { + val objects = ObjectCollectionBuilder() + objects.prediction {} + objects.trip {} + objects.vehicle { currentStatus = Vehicle.CurrentStatus.InTransitTo } + objects.schedule {} + objects.trip {} + + var connectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { LaunchedEffect(Unit) { viewModel.loadStopDetails("stop") } } + + composeTestRule.waitUntil { connectCount == 1 } + + assertEquals(viewModel.stopData.value?.stopId, "stop") + assertNotNull(viewModel.stopData?.value?.predictionsByStop) + assertEquals(1, connectCount) + viewModel.rejoinStopPredictions() + composeTestRule.awaitIdle() + + composeTestRule.waitUntil { connectCount == 2 } + assertEquals(2, connectCount) + } + + @Test + fun testRejoinStopPredictionsWhenNoStop() = runTest { + val objects = ObjectCollectionBuilder() + objects.prediction {} + objects.trip {} + objects.vehicle { currentStatus = Vehicle.CurrentStatus.InTransitTo } + objects.schedule {} + objects.trip {} + + var connectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { LaunchedEffect(Unit) { viewModel.loadStopDetails("stop") } } + + composeTestRule.waitUntil { connectCount == 1 } + + assertEquals(viewModel.stopData.value?.stopId, "stop") + assertNotNull(viewModel.stopData?.value?.predictionsByStop) + assertEquals(1, connectCount) + + viewModel.handleStopChange(null) + composeTestRule.awaitIdle() + + assertNull(viewModel.stopData?.value) + viewModel.rejoinStopPredictions() + // nothing to rejoin + assertEquals(1, connectCount) + } + + @Test + fun testLeaveStopPredictions() = runTest { + val objects = ObjectCollectionBuilder() + objects.prediction {} + objects.trip {} + objects.vehicle { currentStatus = Vehicle.CurrentStatus.InTransitTo } + objects.schedule {} + objects.trip {} + + var connectCount = 0 + var disconnectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 }, + onDisconnect = { disconnectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { LaunchedEffect(Unit) { viewModel.loadStopDetails("stop") } } + + composeTestRule.awaitIdle() + + assertEquals(viewModel.stopData.value?.stopId, "stop") + assertNotNull(viewModel.stopData?.value?.predictionsByStop) + assertEquals(1, connectCount) + assertEquals(0, disconnectCount) + + viewModel.leaveStopPredictions() + + composeTestRule.waitUntil { disconnectCount == 1 } + assertEquals(1, disconnectCount) + } + + @Test + fun testSetDepartures() { + val predictionsRepo = MockPredictionsRepository() + + val schedulesRepo = MockScheduleRepository() + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + assertNull(viewModel.stopDepartures.value) + + viewModel.setDepartures(StopDetailsDepartures(emptyList())) + + assertEquals(emptyList(), viewModel.stopDepartures.value?.routes) + } + + @Test + fun testHandleStopChange() = runTest { + val objects = ObjectCollectionBuilder() + + var connectCount = 0 + var disconnectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 }, + onDisconnect = { disconnectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { + LaunchedEffect(Unit) { + viewModel.loadStopDetails("stop1") + viewModel.handleStopChange("stop2") + } + } + + composeTestRule.waitUntil { connectCount == 2 } + + assertEquals(1, disconnectCount) + assertEquals(2, connectCount) + assertEquals("stop2", viewModel.stopData.value?.stopId) + assertNotNull(viewModel.stopData?.value?.predictionsByStop) + assertNotNull(viewModel.stopData?.value?.schedules) + } + + @Test + fun testManagerHandlesStopChange() = runTest { + val objects = ObjectCollectionBuilder() + + var connectCount = 0 + var disconnectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 }, + onDisconnect = { disconnectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + val stopId = mutableStateOf("stop1") + + val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.STARTED) + + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + var stopId by remember { stopId } + stopDetailsManagedVM( + stopId, + viewModel = viewModel, + globalResponse = null, + alertData = null, + pinnedRoutes = setOf() + ) + } + } + + composeTestRule.waitUntil { connectCount == 1 } + + assertEquals(1, connectCount) + assertEquals(1, disconnectCount) + assertEquals("stop1", viewModel.stopData.value?.stopId) + + stopId.value = "stop2" + + composeTestRule.waitUntil { connectCount == 2 } + + assertEquals(2, connectCount) + assertEquals(2, disconnectCount) + assertEquals("stop2", viewModel.stopData.value?.stopId) + } + + @Test + fun testManagerHandlesBackgrounding() = runTest { + val objects = ObjectCollectionBuilder() + + var connectCount = 0 + var disconnectCount = 0 + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)), + onConnectV2 = { connectCount += 1 }, + onDisconnect = { disconnectCount += 1 } + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + val stopId = mutableStateOf("stop1") + + val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + var stopId by remember { stopId } + stopDetailsManagedVM( + stopId, + viewModel = viewModel, + globalResponse = null, + alertData = null, + pinnedRoutes = setOf() + ) + } + } + + composeTestRule.waitUntil { + // In resumed state, so joined 1 time for stop, 1 time for resume. + // Need to start with lifecycle resumed in order to test pause + connectCount == 2 + } + + assertEquals(2, connectCount) + assertEquals(1, disconnectCount) + assertEquals("stop1", viewModel.stopData.value?.stopId) + + composeTestRule.runOnIdle { lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) } + + composeTestRule.waitUntil { disconnectCount == 2 } + + assertEquals(2, connectCount) + assertEquals(2, disconnectCount) + composeTestRule.runOnIdle { lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } + + composeTestRule.waitUntil { connectCount == 3 } + + assertEquals(3, connectCount) + assertEquals(2, disconnectCount) + } + + @Test + fun testManagerSetsDeparturesOnChange() { + val objects = ObjectCollectionBuilder() + objects.stop { id = "stop1" } + + val predictionsRepo = + MockPredictionsRepository( + connectV2Outcome = ApiResult.Ok(PredictionsByStopJoinResponse(objects)) + ) + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val errorBannerRepo = MockErrorBannerStateRepository() + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + val stopId = mutableStateOf("stop1") + + assertNull(viewModel.stopDepartures.value) + + composeTestRule.setContent { + var stopId by remember { stopId } + stopDetailsManagedVM( + stopId, + viewModel = viewModel, + globalResponse = GlobalResponse(objects), + alertData = AlertsStreamDataResponse(objects), + pinnedRoutes = setOf() + ) + } + + composeTestRule.waitUntil { viewModel.stopDepartures.value != null } + + assertNotNull(viewModel.stopDepartures.value) + } + + @Test + fun testManagerChecksPredictionsStale() { + val objects = ObjectCollectionBuilder() + objects.prediction() + + val schedulesRepo = MockScheduleRepository(ScheduleResponse(objects)) + + val predictionsOnJoin = PredictionsByStopJoinResponse(objects) + val predictionsRepo = MockPredictionsRepository({}, {}, {}, null, predictionsOnJoin) + + predictionsRepo.lastUpdated = Clock.System.now() + + var stopId = mutableStateOf("stop1") + + var checkPredictionsStaleCount = 0 + val errorBannerRepo = + MockErrorBannerStateRepository( + onCheckPredictionsStale = { checkPredictionsStaleCount += 1 } + ) + + val viewModel = StopDetailsViewModel(schedulesRepo, predictionsRepo, errorBannerRepo) + + composeTestRule.setContent { + var stopId by remember { stopId } + stopDetailsManagedVM( + stopId, + viewModel = viewModel, + globalResponse = GlobalResponse(objects), + alertData = AlertsStreamDataResponse(objects), + pinnedRoutes = setOf(), + checkPredictionsStaleInterval = 1.seconds + ) + } + + composeTestRule.waitUntil(timeoutMillis = 3000) { checkPredictionsStaleCount >= 2 } + } +} 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 43e552e1a..122ebeda5 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 @@ -2,6 +2,7 @@ package com.mbta.tid.mbta_app.android.stopDetails import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isHeading @@ -185,6 +186,7 @@ class StopDetailsViewTest { @get:Rule val composeTestRule = createComposeRule() + @OptIn(ExperimentalTestApi::class) @Test fun testStopDetailsViewDisplaysCorrectly() { composeTestRule.setContent { @@ -249,6 +251,8 @@ class StopDetailsViewTest { } } + composeTestRule.waitUntilExactlyOneExists(hasText("Sample Stop")) + composeTestRule.onNode(hasText("Sample Stop") and isHeading()).assertIsDisplayed() composeTestRule.onNodeWithText("Sample Route").assertExists() diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainApplication.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainApplication.kt index d1de52c84..5739d5f82 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainApplication.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainApplication.kt @@ -3,12 +3,13 @@ package com.mbta.tid.mbta_app.android import android.app.Application import com.mbta.tid.mbta_app.android.nearbyTransit.NearbyTransitViewModel import com.mbta.tid.mbta_app.android.phoenix.wrapped +import com.mbta.tid.mbta_app.android.stopDetails.StopDetailsViewModel import com.mbta.tid.mbta_app.android.util.decodeMessage import com.mbta.tid.mbta_app.dependencyInjection.makeNativeModule import com.mbta.tid.mbta_app.initKoin import com.mbta.tid.mbta_app.repositories.AccessibilityStatusRepository import com.mbta.tid.mbta_app.repositories.CurrentAppVersionRepository -import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.core.module.dsl.* import org.koin.dsl.module import org.phoenixframework.Socket @@ -36,6 +37,7 @@ class MainApplication : Application() { val koinViewModelModule = module { viewModelOf(::ContentViewModel) viewModelOf(::NearbyTransitViewModel) + viewModelOf(::StopDetailsViewModel) } } } diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/SheetRoutes.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/SheetRoutes.kt index 83319c3b3..3628535e1 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/SheetRoutes.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/SheetRoutes.kt @@ -18,6 +18,20 @@ sealed class SheetRoutes { val stopFilter: StopDetailsFilter?, val tripFilter: TripDetailsFilter?, ) : SheetRoutes() + + companion object { + /** + * Whether the page within the nearby transit tab changed. Moving from StopDetails to + * StopDetails is only considered a page change if the stopId changed. + */ + fun pageChanged(first: SheetRoutes?, second: SheetRoutes?): Boolean { + return if (first is StopDetails && second is StopDetails) { + first.stopId != second.stopId + } else { + first != second + } + } + } } // Defining types for type-safe navigation with custom objects diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitTabViewModel.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitTabViewModel.kt index 898df6ad8..02c4d6641 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitTabViewModel.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitTabViewModel.kt @@ -8,6 +8,7 @@ import com.mbta.tid.mbta_app.model.StopDetailsDepartures import com.mbta.tid.mbta_app.model.StopDetailsFilter import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update class NearbyTransitTabViewModel : ViewModel() { @@ -16,25 +17,29 @@ class NearbyTransitTabViewModel : ViewModel() { private val _currentNavEntry = MutableStateFlow(null) val currentNavEntry: StateFlow = _currentNavEntry + private val _previousNavEntry = MutableStateFlow(null) + val previousNavEntry: StateFlow = _previousNavEntry /** - * Record the current sheetRoute. Calling this function does *not* affect the navigation stack. - * It as a helper function to more conveniently access the SheetRoute directly rather than - * reading off `NavBackStackEntry`, where it may not always be known what SheetRoute type to - * resolve to. + * Record the current `navEntry` and archive the `previousNavEntry` Calling this function does + * *not* affect the navigation stack. It as a helper function to more conveniently access the + * SheetRoute directly rather than reading off `NavBackStackEntry`, where it may not always be + * known what SheetRoute type to resolve to. */ // https://stackoverflow.com/a/78911122 fun recordCurrentNavEntry(sheetRoute: SheetRoutes?) { - _currentNavEntry.value = sheetRoute + + _currentNavEntry.update { + _previousNavEntry.value = it + sheetRoute + } } fun setStopDetailsDepartures(departures: StopDetailsDepartures?) { _stopDetailsDepartures.value = departures } - // TODO: Move these to separate Navigation utils helper - fun setStopFilter( lastNavEntry: SheetRoutes?, stopFilter: StopDetailsFilter?, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitView.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitView.kt index 9a12ce5a1..43669cbb4 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitView.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/nearbyTransit/NearbyTransitView.kt @@ -76,6 +76,7 @@ fun NearbyTransitView( predictionsVM.reset() } } + val (pinnedRoutes, togglePinnedRoute) = managePinnedRoutes() val nearbyWithRealtimeInfo = diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/NearbyTransitPage.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/NearbyTransitPage.kt index 749c6fc69..8d099f2d1 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/NearbyTransitPage.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/NearbyTransitPage.kt @@ -53,6 +53,8 @@ import com.mbta.tid.mbta_app.android.nearbyTransit.NearbyTransitViewModel import com.mbta.tid.mbta_app.android.nearbyTransit.NoNearbyStopsView import com.mbta.tid.mbta_app.android.search.SearchBarOverlay import com.mbta.tid.mbta_app.android.state.subscribeToVehicles +import com.mbta.tid.mbta_app.android.stopDetails.stopDetailsManagedVM +import com.mbta.tid.mbta_app.android.util.managePinnedRoutes import com.mbta.tid.mbta_app.android.util.toPosition import com.mbta.tid.mbta_app.history.Visit import com.mbta.tid.mbta_app.model.StopDetailsFilter @@ -113,9 +115,24 @@ fun NearbyTransitPage( val navController = rememberNavController() val viewModel: NearbyTransitTabViewModel = viewModel() - val currentNavEntry: SheetRoutes? = viewModel.currentNavEntry.collectAsState(initial = null).value + val previousNavEntry: SheetRoutes? = + viewModel.previousNavEntry.collectAsState(initial = null).value + + val (pinnedRoutes) = managePinnedRoutes() + + val stopDetailsVM = + stopDetailsManagedVM( + stopId = + (when (currentNavEntry) { + is SheetRoutes.StopDetails -> currentNavEntry.stopId + else -> null + }), + globalResponse = nearbyTransit.globalResponse, + alertData = nearbyTransit.alertData, + pinnedRoutes = pinnedRoutes ?: emptySet() + ) val stopDetailsDepartures by viewModel.stopDetailsDepartures.collectAsState() val scope = rememberCoroutineScope() @@ -185,7 +202,9 @@ fun NearbyTransitPage( } LaunchedEffect(currentNavEntry) { - nearbyTransit.scaffoldState.bottomSheetState.animateTo(SheetValue.Medium) + if (SheetRoutes.pageChanged(previousNavEntry, currentNavEntry)) { + nearbyTransit.scaffoldState.bottomSheetState.animateTo(SheetValue.Medium) + } } fun updateStopFilter(stopFilter: StopDetailsFilter?) { @@ -245,8 +264,8 @@ fun NearbyTransitPage( StopDetailsPage( modifier = modifier, + viewModel = stopDetailsVM, filters = filters, - nearbyTransit.alertData, onClose = { navController.popBackStack() }, updateStopFilter = ::updateStopFilter, updateDepartures = { viewModel.setStopDetailsDepartures(it) }, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/StopDetailsPage.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/StopDetailsPage.kt index 22552e5eb..f818570bb 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/StopDetailsPage.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/StopDetailsPage.kt @@ -8,76 +8,30 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.mapbox.maps.MapboxExperimental import com.mbta.tid.mbta_app.android.component.ErrorBannerViewModel -import com.mbta.tid.mbta_app.android.state.getGlobalData -import com.mbta.tid.mbta_app.android.state.getSchedule -import com.mbta.tid.mbta_app.android.state.subscribeToPredictions import com.mbta.tid.mbta_app.android.stopDetails.StopDetailsView +import com.mbta.tid.mbta_app.android.stopDetails.StopDetailsViewModel import com.mbta.tid.mbta_app.android.util.managePinnedRoutes -import com.mbta.tid.mbta_app.android.util.rememberSuspend -import com.mbta.tid.mbta_app.android.util.timer import com.mbta.tid.mbta_app.model.StopDetailsDepartures import com.mbta.tid.mbta_app.model.StopDetailsFilter import com.mbta.tid.mbta_app.model.StopDetailsPageFilters -import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable @ExperimentalMaterial3Api @MapboxExperimental fun StopDetailsPage( modifier: Modifier = Modifier, + viewModel: StopDetailsViewModel, filters: StopDetailsPageFilters, - alertData: AlertsStreamDataResponse?, onClose: () -> Unit, updateStopFilter: (StopDetailsFilter?) -> Unit, updateDepartures: (StopDetailsDepartures?) -> Unit, errorBannerViewModel: ErrorBannerViewModel ) { - val globalResponse = getGlobalData("StopDetailsPage.getGlobalData") - val stopId = filters.stopId - val predictionsVM = - subscribeToPredictions( - stopIds = listOf(filters.stopId), - errorBannerViewModel = errorBannerViewModel - ) - val predictionsResponse by predictionsVM.predictionsFlow.collectAsState(initial = null) - - val now = timer(updateInterval = 5.seconds) - - val schedulesResponse = - getSchedule(stopIds = listOf(filters.stopId), "StopDetailsPage.getSchedule") - val (pinnedRoutes, togglePinnedRoute) = managePinnedRoutes() - val departures = - rememberSuspend( - stopId, - globalResponse, - schedulesResponse, - predictionsResponse, - alertData, - pinnedRoutes, - now - ) { - withContext(Dispatchers.Default) { - if (globalResponse != null) { - StopDetailsDepartures.fromData( - stopId, - globalResponse, - schedulesResponse, - predictionsResponse, - alertData, - pinnedRoutes.orEmpty(), - now, - useTripHeadsigns = false, - ) - } else null - } - } + val departures by viewModel.stopDepartures.collectAsState() LaunchedEffect(departures) { updateDepartures(departures) } diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getSchedule.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getSchedule.kt index 22b922b42..65e392e87 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getSchedule.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getSchedule.kt @@ -18,28 +18,44 @@ import kotlinx.coroutines.launch import kotlinx.datetime.Clock import org.koin.compose.koinInject -class ScheduleViewModel( +class ScheduleFetcher( private val schedulesRepository: ISchedulesRepository, private val errorBannerRepository: IErrorBannerStateRepository -) : ViewModel() { - private val _schedule = MutableStateFlow(null) - val schedule: StateFlow = _schedule +) { - fun getSchedule(stopIds: List, errorKey: String) { + fun getSchedule( + stopIds: List, + errorKey: String, + onSuccess: (ScheduleResponse) -> Unit + ) { if (stopIds.isNotEmpty()) { CoroutineScope(Dispatchers.IO).launch { fetchApi( errorBannerRepo = errorBannerRepository, errorKey = errorKey, getData = { schedulesRepository.getSchedule(stopIds, Clock.System.now()) }, - onSuccess = { _schedule.value = it }, - onRefreshAfterError = { getSchedule(stopIds, errorKey) } + onSuccess = { onSuccess(it) }, + onRefreshAfterError = { getSchedule(stopIds, errorKey, onSuccess) } ) } } else { - _schedule.value = ScheduleResponse(emptyList(), emptyMap()) + onSuccess(ScheduleResponse(emptyList(), emptyMap())) } } +} + +class ScheduleViewModel( + private val schedulesRepository: ISchedulesRepository, + private val errorBannerRepository: IErrorBannerStateRepository +) : ViewModel() { + + private val scheduleFetcher = ScheduleFetcher(schedulesRepository, errorBannerRepository) + private val _schedule = MutableStateFlow(null) + val schedule: StateFlow = _schedule + + fun getSchedule(stopIds: List, errorKey: String) { + scheduleFetcher.getSchedule(stopIds, errorKey) { _schedule.value = it } + } class Factory( private val schedulesRepository: ISchedulesRepository, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/subscribeToPredictions.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/subscribeToPredictions.kt index 21c70cc20..0ffda7474 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/subscribeToPredictions.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/subscribeToPredictions.kt @@ -12,6 +12,7 @@ import com.mbta.tid.mbta_app.android.util.timer import com.mbta.tid.mbta_app.model.response.ApiResult import com.mbta.tid.mbta_app.model.response.PredictionsByStopJoinResponse import com.mbta.tid.mbta_app.model.response.PredictionsByStopMessageResponse +import com.mbta.tid.mbta_app.repositories.IErrorBannerStateRepository import com.mbta.tid.mbta_app.repositories.IPredictionsRepository import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -21,27 +22,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent -class PredictionsViewModel( +class StopPredictionsFetcher( private val predictionsRepository: IPredictionsRepository, - private val errorBannerViewModel: ErrorBannerViewModel -) : KoinComponent, ViewModel() { - private val _predictions = MutableStateFlow(null) - - val predictions: StateFlow = _predictions - val predictionsFlow = - predictions.debounce(0.1.seconds).map { it?.toPredictionsStreamDataResponse() } + private val errorRepository: IErrorBannerStateRepository, + private val onJoinResponse: (PredictionsByStopJoinResponse) -> Unit, + private val onPushMessage: (PredictionsByStopMessageResponse) -> PredictionsByStopJoinResponse? +) { private var currentStopIds: List? = null - override fun onCleared() { - super.onCleared() - predictionsRepository.disconnect() - } - fun connect(stopIds: List?) { currentStopIds = stopIds if (stopIds != null) { @@ -52,9 +46,8 @@ class PredictionsViewModel( private fun handleJoinMessage(message: ApiResult) { when (message) { is ApiResult.Ok -> { - _predictions.value = message.data - errorBannerViewModel.loadingWhenPredictionsStale = false - checkPredictionsStale() + onJoinResponse(message.data) + checkPredictionsStale(message.data) } is ApiResult.Error -> { Log.e( @@ -68,15 +61,8 @@ class PredictionsViewModel( private fun handlePushMessage(message: ApiResult) { when (message) { is ApiResult.Ok -> { - _predictions.value = - (_predictions.value - ?: PredictionsByStopJoinResponse( - mapOf(message.data.stopId to message.data.predictions), - message.data.trips, - message.data.vehicles - )) - .mergePredictions(message.data) - checkPredictionsStale() + val latestPredictions = onPushMessage(message.data) + latestPredictions?.let { checkPredictionsStale(it) } } is ApiResult.Error -> { Log.e( @@ -87,21 +73,16 @@ class PredictionsViewModel( } } - fun reset() { - _predictions.value = null - } - fun disconnect() { predictionsRepository.disconnect() - errorBannerViewModel.loadingWhenPredictionsStale = true } - fun checkPredictionsStale() { + fun checkPredictionsStale(predictions: PredictionsByStopJoinResponse?) { CoroutineScope(Dispatchers.IO).launch { predictionsRepository.lastUpdated?.let { lastPredictions -> - errorBannerViewModel.errorRepository.checkPredictionsStale( + errorRepository.checkPredictionsStale( predictionsLastUpdated = lastPredictions, - predictionQuantity = predictions.value?.predictionQuantity() ?: 0, + predictionQuantity = predictions?.predictionQuantity() ?: 0, action = { disconnect() connect(currentStopIds) @@ -110,6 +91,61 @@ class PredictionsViewModel( } } } +} + +class PredictionsViewModel( + private val predictionsRepository: IPredictionsRepository, + private val errorBannerViewModel: ErrorBannerViewModel +) : KoinComponent, ViewModel() { + private val _predictions = MutableStateFlow(null) + val predictions: StateFlow = _predictions + val predictionsFlow = + predictions.debounce(0.1.seconds).map { it?.toPredictionsStreamDataResponse() } + + private val stopPredictionsFetcher = + StopPredictionsFetcher( + predictionsRepository, + errorBannerViewModel.errorRepository, + ::onJoinResponse, + ::onPushMessage + ) + + fun onJoinResponse(joinResponse: PredictionsByStopJoinResponse) { + _predictions.value = joinResponse + errorBannerViewModel.loadingWhenPredictionsStale = false + } + + fun onPushMessage( + pushMessage: PredictionsByStopMessageResponse + ): PredictionsByStopJoinResponse? { + return _predictions.updateAndGet { currentPredictions -> + val currentPredictions = + currentPredictions ?: PredictionsByStopJoinResponse(mapOf(), mapOf(), mapOf()) + currentPredictions.mergePredictions(pushMessage) + } + } + + fun connect(stopIds: List?) { + stopPredictionsFetcher.connect(stopIds) + } + + fun disconnect() { + stopPredictionsFetcher.disconnect() + errorBannerViewModel.loadingWhenPredictionsStale = true + } + + override fun onCleared() { + super.onCleared() + stopPredictionsFetcher.disconnect() + } + + fun reset() { + _predictions.value = null + } + + fun checkPredictionsStale() { + stopPredictionsFetcher.checkPredictionsStale(_predictions.value) + } class Factory( private val predictionsRepository: IPredictionsRepository, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsView.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsView.kt index 311e3d0c0..adc762daa 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsView.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsView.kt @@ -129,7 +129,6 @@ fun StopDetailsView( } } } - StopDetailsRoutesView( departures, globalResponse, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModel.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModel.kt index bf84a6f74..112357c8d 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModel.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/stopDetails/StopDetailsViewModel.kt @@ -1,5 +1,196 @@ package com.mbta.tid.mbta_app.android.stopDetails +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.mbta.tid.mbta_app.android.state.ScheduleFetcher +import com.mbta.tid.mbta_app.android.state.StopPredictionsFetcher +import com.mbta.tid.mbta_app.android.util.timer +import com.mbta.tid.mbta_app.model.StopDetailsDepartures +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.PredictionsByStopJoinResponse +import com.mbta.tid.mbta_app.model.response.PredictionsByStopMessageResponse +import com.mbta.tid.mbta_app.model.stopDetailsPage.StopData +import com.mbta.tid.mbta_app.repositories.IErrorBannerStateRepository +import com.mbta.tid.mbta_app.repositories.IPredictionsRepository +import com.mbta.tid.mbta_app.repositories.ISchedulesRepository +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withContext +import org.koin.androidx.compose.koinViewModel -class StopDetailsViewModel() : ViewModel() {} +class StopDetailsViewModel( + schedulesRepository: ISchedulesRepository, + private val predictionsRepository: IPredictionsRepository, + private val errorBannerRepository: IErrorBannerStateRepository, +) : ViewModel() { + private val stopScheduleFetcher = ScheduleFetcher(schedulesRepository, errorBannerRepository) + + private val _stopData = MutableStateFlow(null) + val stopData: StateFlow = _stopData + + private val _stopDepartures = MutableStateFlow(null) + val stopDepartures: StateFlow = _stopDepartures + + private val stopPredictionsFetcher = + StopPredictionsFetcher( + predictionsRepository, + errorBannerRepository, + ::onJoinMessage, + ::onPushMessage + ) + + fun loadStopDetails(stopId: String) { + _stopData.value = StopData(stopId, null, null, false) + getStopSchedules(stopId) + joinStopPredictions(stopId) + } + + private fun onJoinMessage(message: PredictionsByStopJoinResponse) { + _stopData.update { it -> it?.let { StopData(it.stopId, it.schedules, message, true) } } + } + + private fun onPushMessage( + message: PredictionsByStopMessageResponse + ): PredictionsByStopJoinResponse? { + + return _stopData + ?.updateAndGet { + it?.let { + StopData( + it.stopId, + it.schedules, + (it.predictionsByStop + ?: PredictionsByStopJoinResponse( + mapOf(it.stopId to message.predictions), + message.trips, + message.vehicles + )) + .mergePredictions(message) + ) + } + } + ?.predictionsByStop + } + + private fun getStopSchedules(stopId: String) { + stopScheduleFetcher.getSchedule(listOf(stopId), "StopDetailsVM.getSchedule") { schedules -> + _stopData.update { + it?.let { + StopData(it.stopId, schedules, it.predictionsByStop, it.predictionsLoaded) + } + } + } + } + + private fun joinStopPredictions(stopId: String) { + stopPredictionsFetcher.connect(listOf(stopId)) + } + + fun rejoinStopPredictions() { + + stopData.value?.let { joinStopPredictions(it.stopId) } + } + + fun leaveStopPredictions() { + stopPredictionsFetcher.disconnect() + } + + fun setDepartures(departures: StopDetailsDepartures?) { + _stopDepartures.value = departures + } + + private fun clearStopDetails() { + stopPredictionsFetcher.disconnect() + _stopData.value = null + errorBannerRepository.clearDataError("StopDetailsVM.getSchedule") + } + + fun handleStopChange(stopId: String?) { + clearStopDetails() + + if (stopId != null) { + loadStopDetails(stopId) + } + } + + fun checkPredictionsStale() { + stopPredictionsFetcher.checkPredictionsStale(_stopData.value?.predictionsByStop) + } + + // Clear predictions if too stale + fun returnFromBackground() { + _stopData.update { + if ( + it != null && + predictionsRepository.shouldForgetPredictions( + it?.predictionsByStop?.predictionQuantity() ?: 0 + ) + ) { + StopData(it.stopId, it.schedules, null, false) + } else { + it + } + } + } +} + +@Composable +/** + * Manage stop details data and lifecycle events, including: + * - refetching data when the selected stopId changes + * - resubscribing to predictions when returning from background + * - periodically checking for stale predictions + */ +fun stopDetailsManagedVM( + stopId: String?, + globalResponse: GlobalResponse?, + alertData: AlertsStreamDataResponse?, + pinnedRoutes: Set, + viewModel: StopDetailsViewModel = koinViewModel(), + checkPredictionsStaleInterval: Duration = 5.seconds +): StopDetailsViewModel { + val now = timer(updateInterval = 5.seconds) + val timer = timer(checkPredictionsStaleInterval) + + val stopData = viewModel.stopData.collectAsState() + + LaunchedEffect(stopId) { viewModel.handleStopChange(stopId) } + LifecycleResumeEffect(null) { + viewModel.returnFromBackground() + viewModel.rejoinStopPredictions() + + onPauseOrDispose { viewModel.leaveStopPredictions() } + } + + LaunchedEffect(stopId, globalResponse, stopData, stopId, alertData, pinnedRoutes, now) { + withContext(Dispatchers.Default) { + val departures: StopDetailsDepartures? = + if (globalResponse != null && stopId != null) { + StopDetailsDepartures.fromData( + stopId, + globalResponse, + stopData.value?.schedules, + stopData.value?.predictionsByStop?.toPredictionsStreamDataResponse(), + alertData, + pinnedRoutes, + now, + useTripHeadsigns = false, + ) + } else null + viewModel.setDepartures(departures) + } + } + + LaunchedEffect(key1 = timer) { viewModel.checkPredictionsStale() } + + return viewModel +} diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/stopDetailsPage/StopData.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/stopDetailsPage/StopData.kt new file mode 100644 index 000000000..6787b433d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/stopDetailsPage/StopData.kt @@ -0,0 +1,13 @@ +package com.mbta.tid.mbta_app.model.stopDetailsPage + +import com.mbta.tid.mbta_app.model.response.PredictionsByStopJoinResponse +import com.mbta.tid.mbta_app.model.response.ScheduleResponse +import kotlinx.serialization.Serializable + +@Serializable +data class StopData( + val stopId: String, + val schedules: ScheduleResponse?, + val predictionsByStop: PredictionsByStopJoinResponse?, + val predictionsLoaded: Boolean = false +)