diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 80b351071..1554c4b2e 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(libs.mapbox.compose) implementation(libs.mapbox.turf) implementation(libs.okhttp) + implementation(libs.playServices.location) debugImplementation(platform(libs.compose.bom)) debugImplementation(libs.compose.ui.test.manifest) debugImplementation(libs.compose.ui.tooling) @@ -65,6 +66,8 @@ dependencies { testImplementation(libs.koin.test) implementation(libs.koin.junit4) androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.test.monitor) + androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.ktor.client.mock) } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/ContentViewTests.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/ContentViewTests.kt index b6b7cf42a..62658ced8 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/ContentViewTests.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/ContentViewTests.kt @@ -1,9 +1,16 @@ package com.mbta.tid.mbta_app.android +import android.app.Activity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.rule.GrantPermissionRule +import com.mbta.tid.mbta_app.android.location.MockFusedLocationProviderClient +import com.mbta.tid.mbta_app.android.util.LocalActivity +import com.mbta.tid.mbta_app.android.util.LocalLocationClient import com.mbta.tid.mbta_app.dependencyInjection.repositoriesModule import com.mbta.tid.mbta_app.network.MockPhoenixSocket import com.mbta.tid.mbta_app.network.PhoenixSocket @@ -16,6 +23,9 @@ import org.koin.test.KoinTest class ContentViewTests : KoinTest { + @get:Rule + val runtimePermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION) @get:Rule val composeTestRule = createComposeRule() val koinApplication = koinApplication { @@ -27,7 +37,16 @@ class ContentViewTests : KoinTest { @Test fun testSwitchingTabs() { - composeTestRule.setContent { KoinContext(koinApplication.koin) { ContentView() } } + composeTestRule.setContent { + KoinContext(koinApplication.koin) { + CompositionLocalProvider( + LocalActivity provides (LocalContext.current as Activity), + LocalLocationClient provides MockFusedLocationProviderClient() + ) { + ContentView() + } + } + } composeTestRule.onNodeWithText("More").performClick() composeTestRule.onNodeWithText("MBTA Go").assertIsDisplayed() diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockFusedLocationProviderClient.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockFusedLocationProviderClient.kt new file mode 100644 index 000000000..076292550 --- /dev/null +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockFusedLocationProviderClient.kt @@ -0,0 +1,126 @@ +package com.mbta.tid.mbta_app.android.location + +import android.app.PendingIntent +import android.location.Location +import android.os.Looper +import com.google.android.gms.common.api.Api +import com.google.android.gms.common.api.internal.ApiKey +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.DeviceOrientationListener +import com.google.android.gms.location.DeviceOrientationRequest +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LastLocationRequest +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationListener +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.tasks.CancellationToken +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import java.util.concurrent.Executor + +class MockFusedLocationProviderClient : FusedLocationProviderClient { + override fun getApiKey(): ApiKey { + TODO("Not yet implemented") + } + + override fun getLastLocation(): Task = Tasks.forCanceled() + + override fun getLastLocation(p0: LastLocationRequest): Task { + TODO("Not yet implemented") + } + + override fun getCurrentLocation(p0: Int, p1: CancellationToken?): Task { + TODO("Not yet implemented") + } + + override fun getCurrentLocation( + p0: CurrentLocationRequest, + p1: CancellationToken? + ): Task { + TODO("Not yet implemented") + } + + override fun getLocationAvailability(): Task { + TODO("Not yet implemented") + } + + override fun requestLocationUpdates( + p0: LocationRequest, + p1: Executor, + p2: LocationListener + ): Task { + TODO("Not yet implemented") + } + + override fun requestLocationUpdates( + p0: LocationRequest, + p1: LocationListener, + p2: Looper? + ): Task = Tasks.forCanceled() + + override fun requestLocationUpdates( + p0: LocationRequest, + p1: LocationCallback, + p2: Looper? + ): Task { + TODO("Not yet implemented") + } + + override fun requestLocationUpdates( + p0: LocationRequest, + p1: Executor, + p2: LocationCallback + ): Task { + TODO("Not yet implemented") + } + + override fun requestLocationUpdates(p0: LocationRequest, p1: PendingIntent): Task { + TODO("Not yet implemented") + } + + override fun removeLocationUpdates(p0: LocationListener): Task = Tasks.forCanceled() + + override fun removeLocationUpdates(p0: LocationCallback): Task { + TODO("Not yet implemented") + } + + override fun removeLocationUpdates(p0: PendingIntent): Task { + TODO("Not yet implemented") + } + + override fun flushLocations(): Task { + TODO("Not yet implemented") + } + + override fun setMockMode(p0: Boolean): Task { + TODO("Not yet implemented") + } + + override fun setMockLocation(p0: Location): Task { + TODO("Not yet implemented") + } + + @Deprecated("Deprecated in Java") + override fun requestDeviceOrientationUpdates( + p0: DeviceOrientationRequest, + p1: Executor, + p2: DeviceOrientationListener + ): Task { + TODO("Not yet implemented") + } + + @Deprecated("Deprecated in Java") + override fun requestDeviceOrientationUpdates( + p0: DeviceOrientationRequest, + p1: DeviceOrientationListener, + p2: Looper? + ): Task { + TODO("Not yet implemented") + } + + @Deprecated("Deprecated in Java") + override fun removeDeviceOrientationUpdates(p0: DeviceOrientationListener): Task { + TODO("Not yet implemented") + } +} diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockLocationDataManager.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockLocationDataManager.kt new file mode 100644 index 000000000..c7966f69e --- /dev/null +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/location/MockLocationDataManager.kt @@ -0,0 +1,8 @@ +package com.mbta.tid.mbta_app.android.location + +import android.location.Location +import kotlinx.coroutines.flow.MutableStateFlow + +class MockLocationDataManager(location: Location) : LocationDataManager() { + override val currentLocation = MutableStateFlow(location) +} 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 70b3d3e0d..bb0d5256c 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 @@ -1,18 +1,27 @@ package com.mbta.tid.mbta_app.android.nearbyTransit +import android.app.Activity +import android.location.Location import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText -import com.mapbox.geojson.Point -import com.mapbox.maps.CameraState -import com.mapbox.maps.EdgeInsets +import androidx.test.rule.GrantPermissionRule import com.mapbox.maps.MapboxExperimental -import com.mapbox.maps.extension.compose.animation.viewport.MapViewportState +import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState +import com.mbta.tid.mbta_app.android.location.MockFusedLocationProviderClient +import com.mbta.tid.mbta_app.android.location.MockLocationDataManager +import com.mbta.tid.mbta_app.android.location.ViewportProvider 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.util.LocalActivity +import com.mbta.tid.mbta_app.android.util.LocalLocationClient import com.mbta.tid.mbta_app.model.Coordinate import com.mbta.tid.mbta_app.model.LocationType import com.mbta.tid.mbta_app.model.NearbyStaticData @@ -233,40 +242,41 @@ class NearbyTransitPageTest : KoinTest { ) } + @get:Rule + val runtimePermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION) @get:Rule val composeTestRule = createComposeRule() + @OptIn(ExperimentalTestApi::class) @Test fun testNearbyTransitPageDisplaysCorrectly() { composeTestRule.setContent { KoinContext(koinApplication.koin) { - NearbyTransitPage( - Modifier, - NearbyTransit( - alertData = AlertsStreamDataResponse(builder.alerts), - globalResponse = globalResponse, - targetLocation = Position(0.0, 0.0), - lastNearbyTransitLocation = Position(0.0, 0.0), - mapCenter = Position(0.0, 0.0), - mapViewportState = - MapViewportState( - CameraState( - Point.fromLngLat(0.0, 0.0), - EdgeInsets(0.0, 0.0, 0.0, 0.0), - 1.0, - 0.0, - 0.0 - ) - ), - scaffoldState = rememberBottomSheetScaffoldState(), - ), - false, - {}, - {}, - bottomBar = {} - ) + CompositionLocalProvider( + LocalActivity provides (LocalContext.current as Activity), + LocalLocationClient provides MockFusedLocationProviderClient() + ) { + NearbyTransitPage( + Modifier, + NearbyTransit( + alertData = AlertsStreamDataResponse(builder.alerts), + globalResponse = globalResponse, + lastNearbyTransitLocation = Position(0.0, 0.0), + scaffoldState = rememberBottomSheetScaffoldState(), + locationDataManager = MockLocationDataManager(Location("mock")), + viewportProvider = ViewportProvider(rememberMapViewportState()), + ), + false, + {}, + {}, + bottomBar = {} + ) + } } } + composeTestRule.waitUntilDoesNotExist(hasText("Loading...")) + composeTestRule.onNodeWithText("Nearby transit").assertIsDisplayed() composeTestRule.onNodeWithText("Green Line Long Name").assertExists() diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/ContentView.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/ContentView.kt index cdaddcd5c..779d4fe6a 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/ContentView.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/ContentView.kt @@ -7,38 +7,32 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.mapbox.geojson.Point import com.mapbox.maps.MapboxExperimental import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState import com.mbta.tid.mbta_app.android.component.BottomNavBar +import com.mbta.tid.mbta_app.android.location.ViewportProvider +import com.mbta.tid.mbta_app.android.location.rememberLocationDataManager import com.mbta.tid.mbta_app.android.pages.MorePage 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.phoenix.PhoenixSocketWrapper import com.mbta.tid.mbta_app.android.state.getGlobalData import com.mbta.tid.mbta_app.android.state.subscribeToAlerts -import com.mbta.tid.mbta_app.android.util.toPosition import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse import com.mbta.tid.mbta_app.network.PhoenixSocket import io.github.dellisd.spatialk.geojson.Position -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map import org.koin.compose.koinInject -@OptIn(MapboxExperimental::class, ExperimentalMaterial3Api::class, FlowPreview::class) +@OptIn(MapboxExperimental::class, ExperimentalMaterial3Api::class) @Composable fun ContentView( socket: PhoenixSocket = koinInject(), @@ -46,24 +40,17 @@ fun ContentView( val navController = rememberNavController() var alertData: AlertsStreamDataResponse? = subscribeToAlerts() val globalResponse = getGlobalData() + val locationDataManager = rememberLocationDataManager() val mapViewportState = rememberMapViewportState { setCameraOptions { - center(Point.fromLngLat(-71.062424, 42.356395)) - zoom(13.25) + center(ViewportProvider.Companion.Defaults.center) + zoom(ViewportProvider.Companion.Defaults.zoom) pitch(0.0) bearing(0.0) transitionToFollowPuckState() } } - val rawMapCenterFlow = snapshotFlow { mapViewportState.cameraState.center } - val mapCenterFlow = - remember(rawMapCenterFlow) { - rawMapCenterFlow.debounce(0.25.seconds).map { it.toPosition() } - } - val mapCenter by - mapCenterFlow.collectAsState( - initial = Position(longitude = -71.062424, latitude = 42.356395) - ) + val viewportProvider = remember { ViewportProvider(mapViewportState) } val lastNearbyTransitLocation by remember { mutableStateOf(null) } val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = rememberStandardBottomSheetState()) @@ -82,11 +69,10 @@ fun ContentView( NearbyTransit( alertData = alertData, globalResponse = globalResponse, - targetLocation = mapCenter, - mapCenter = mapCenter, lastNearbyTransitLocation = lastNearbyTransitLocation, scaffoldState = scaffoldState, - mapViewportState = mapViewportState, + locationDataManager = locationDataManager, + viewportProvider = viewportProvider, ), navBarVisible = navBarVisible, showNavBar = { navBarVisible = true }, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainActivity.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainActivity.kt index 1ad0f6c48..08f84634b 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainActivity.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/MainActivity.kt @@ -6,18 +6,31 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.mbta.tid.mbta_app.android.util.LocalActivity +import com.mbta.tid.mbta_app.android.util.LocalLocationClient class MainActivity : ComponentActivity() { + private lateinit var fusedLocationClient: FusedLocationProviderClient + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) setContent { MyApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - ContentView() + CompositionLocalProvider( + LocalActivity provides this, + LocalLocationClient provides fusedLocationClient, + ) { + ContentView() + } } } } diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/LocationDataManager.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/LocationDataManager.kt new file mode 100644 index 000000000..562066f2c --- /dev/null +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/LocationDataManager.kt @@ -0,0 +1,148 @@ +package com.mbta.tid.mbta_app.android.location + +import android.Manifest +import android.annotation.SuppressLint +import android.content.IntentSender +import android.location.Location +import android.os.Looper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.location.LocationListener +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.LocationSettingsRequest +import com.google.android.gms.location.Priority +import com.mbta.tid.mbta_app.android.util.LocalActivity +import com.mbta.tid.mbta_app.android.util.LocalLocationClient +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +open class LocationDataManager { + private val _currentLocation = MutableStateFlow(null) + + var hasPermission by mutableStateOf(false) + + /** + * Attach the event handlers for this [LocationDataManager] in the context of the current + * composition. Must be called once and only once, ideally by [rememberLocationDataManager]. + */ + // with reference to + // https://github.com/android/platform-samples/blob/20c7a4e5016fcfefbea6c598f95c51477b073a1f/samples/location/src/main/java/com/example/platform/location/locationupdates/LocationUpdatesScreen.kt + @SuppressLint("MissingPermission") + @OptIn(ExperimentalPermissionsApi::class) + @Composable + fun running(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): LocationDataManager { + val permissions = rememberPermissions() + val locationRequest = + remember(permissions) { + val finePermission = + permissions.permissions.find { + it.permission == Manifest.permission.ACCESS_FINE_LOCATION + } + LocationRequest.Builder(5.seconds.inWholeMilliseconds) + // ignore updates less than 0.1km + .setMinUpdateDistanceMeters(100F) + .setPriority( + if (finePermission?.status?.isGranted == true) + Priority.PRIORITY_HIGH_ACCURACY + else Priority.PRIORITY_BALANCED_POWER_ACCURACY + ) + .build() + } + + val settingsRequest = + remember(locationRequest) { + LocationSettingsRequest.Builder().addLocationRequest(locationRequest).build() + } + + val activity = LocalActivity.current + val context = LocalContext.current + hasPermission = permissions.permissions.any { it.status.isGranted } + + val locationClient = LocalLocationClient.current + + var settingsCorrect by remember { mutableStateOf(false) } + + if (hasPermission) { + LaunchedEffect(Unit) { + locationClient.lastLocation.addOnSuccessListener { location: Location? -> + _currentLocation.tryEmit(location) + } + } + + // https://developer.android.com/develop/sensors-and-location/location/change-location-settings#prompt + LaunchedEffect(settingsRequest) { + val settingsClient = LocationServices.getSettingsClient(context) + val task = settingsClient.checkLocationSettings(settingsRequest) + + task.addOnSuccessListener { settingsCorrect = true } + task.addOnFailureListener { exception -> + if (exception is ResolvableApiException) { + try { + exception.startResolutionForResult(activity, 1) + } catch (sendEx: IntentSender.SendIntentException) { + // sample ignores this so we do too + } + } + } + } + } + + if (hasPermission && settingsCorrect) { + DisposableEffect(locationRequest, lifecycleOwner) { + val locationCallback = LocationListener { location -> + _currentLocation.tryEmit(location) + } + val lifecycleObserver = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + locationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } else if (event == Lifecycle.Event.ON_STOP) { + locationClient.removeLocationUpdates(locationCallback) + } + } + + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + + onDispose { + locationClient.removeLocationUpdates(locationCallback) + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + } + } + } + + return this + } + + @OptIn(ExperimentalPermissionsApi::class) + @Composable + fun rememberPermissions() = + rememberMultiplePermissionsState( + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + + open val currentLocation = _currentLocation.asStateFlow() +} + +@Composable fun rememberLocationDataManager() = remember { LocationDataManager() }.running() diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/ViewportProvider.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/ViewportProvider.kt new file mode 100644 index 000000000..ee0bc093f --- /dev/null +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/location/ViewportProvider.kt @@ -0,0 +1,143 @@ +package com.mbta.tid.mbta_app.android.location + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.mapbox.geojson.Point +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.CameraState +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapboxExperimental +import com.mapbox.maps.extension.compose.animation.viewport.MapViewportState +import com.mapbox.maps.plugin.animation.MapAnimationOptions +import com.mapbox.maps.plugin.viewport.data.DefaultViewportTransitionOptions +import com.mapbox.maps.plugin.viewport.data.FollowPuckViewportStateOptions +import com.mbta.tid.mbta_app.android.util.MapAnimationDefaults +import com.mbta.tid.mbta_app.android.util.ViewportSnapshot +import com.mbta.tid.mbta_app.android.util.followPuck +import com.mbta.tid.mbta_app.android.util.isFollowingPuck +import com.mbta.tid.mbta_app.android.util.isRoughlyEqualTo +import com.mbta.tid.mbta_app.map.MapDefaults +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +class ViewportProvider +@OptIn(MapboxExperimental::class) +constructor(var viewport: MapViewportState, isManuallyCentering: Boolean = false) { + var isManuallyCentering by mutableStateOf(isManuallyCentering) + @OptIn(MapboxExperimental::class) + var isFollowingPuck by mutableStateOf(viewport.isFollowingPuck) + + private var savedNearbyTransitViewport: ViewportSnapshot? = null + + @OptIn(MapboxExperimental::class) + private var _cameraState = + MutableStateFlow( + // For some reason, the initial state of the viewport doesn't apply immediately, so the + // viewport may be completely uninitialized here; if it is, use the defaults. + viewport.cameraState.takeUnless { it.zoom == 0.0 } + ?: CameraState( + Defaults.center, + EdgeInsets(0.0, 0.0, 0.0, 0.0), + Defaults.zoom, + 0.0, + 0.0 + ) + ) + var cameraStateFlow = + _cameraState.asStateFlow().distinctUntilChanged { old, new -> + old.center.isRoughlyEqualTo(new.center) + } + + @OptIn(MapboxExperimental::class) + fun follow( + defaultTransitionOptions: DefaultViewportTransitionOptions = Defaults.viewportTransition + ) { + isFollowingPuck = true + this.viewport.transitionToFollowPuckState( + followPuckViewportStateOptions = + FollowPuckViewportStateOptions.Builder() + .apply { + bearing(null) + pitch(null) + zoom(_cameraState.value.zoom) + } + .build(), + defaultTransitionOptions = defaultTransitionOptions + ) + } + + @OptIn(MapboxExperimental::class) + fun isDefault() = viewport.cameraState.center.isRoughlyEqualTo(Defaults.center) + + fun animateTo( + coordinates: Point, + animation: MapAnimationOptions = MapAnimationDefaults.options, + zoom: Double? = null + ) { + animateToCamera( + options = + CameraOptions.Builder() + .center(coordinates) + .zoom(zoom ?: _cameraState.value.zoom) + .build(), + animation = animation + ) + } + + @OptIn(MapboxExperimental::class) + fun animateToCamera( + options: CameraOptions, + animation: MapAnimationOptions = MapAnimationDefaults.options + ) { + this.viewport.easeTo(options, animation) + } + + fun updateCameraState(state: CameraState) { + _cameraState.tryEmit(state) + } + + @OptIn(MapboxExperimental::class) + fun saveCurrentViewport() { + val camera = _cameraState.value + if (viewport.isFollowingPuck) { + viewport.followPuck(zoom = camera.zoom) + } else { + viewport.setCameraOptions { + center(camera.center) + zoom(camera.zoom) + } + } + } + + @OptIn(MapboxExperimental::class) + fun saveNearbyTransitViewport() { + savedNearbyTransitViewport = ViewportSnapshot(viewport) + } + + @OptIn(MapboxExperimental::class) + fun restoreNearbyTransitViewport() { + // TODO preserve zoom + savedNearbyTransitViewport?.restoreOn(viewport) + savedNearbyTransitViewport = null + } + + fun setIsManuallyCentering(isManuallyCentering: Boolean) { + this.isManuallyCentering = isManuallyCentering + if (isManuallyCentering) { + isFollowingPuck = false + } + } + + companion object { + object Defaults { + val viewportTransition = + DefaultViewportTransitionOptions.Builder() + .maxDurationMs(MapAnimationDefaults.duration) + .build() + val center: Point = Point.fromLngLat(-71.0601, 42.3575) + val zoom = MapDefaults.defaultZoomThreshold + } + } +} diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/HomeMapView.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/HomeMapView.kt index 634592e51..1f6ae4b16 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/HomeMapView.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/HomeMapView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -16,40 +17,39 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.navigation.NavBackStackEntry import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.mapbox.geojson.FeatureCollection import com.mapbox.geojson.Point -import com.mapbox.maps.CameraOptions import com.mapbox.maps.MapView import com.mapbox.maps.MapboxExperimental import com.mapbox.maps.RenderedQueryGeometry import com.mapbox.maps.RenderedQueryOptions import com.mapbox.maps.Style import com.mapbox.maps.ViewAnnotationAnchor +import com.mapbox.maps.extension.compose.DisposableMapEffect import com.mapbox.maps.extension.compose.MapEffect import com.mapbox.maps.extension.compose.MapEvents import com.mapbox.maps.extension.compose.MapboxMap -import com.mapbox.maps.extension.compose.animation.viewport.MapViewportState import com.mapbox.maps.extension.compose.annotation.ViewAnnotation import com.mapbox.maps.extension.compose.annotation.generated.CircleAnnotation import com.mapbox.maps.extension.compose.style.MapStyle import com.mapbox.maps.plugin.gestures.addOnMapClickListener import com.mapbox.maps.plugin.gestures.generated.GesturesSettings +import com.mapbox.maps.plugin.gestures.gestures import com.mapbox.maps.plugin.locationcomponent.createDefault2DPuck import com.mapbox.maps.plugin.locationcomponent.generated.LocationComponentSettings +import com.mapbox.maps.plugin.locationcomponent.location +import com.mapbox.maps.plugin.viewport.data.DefaultViewportTransitionOptions import com.mapbox.maps.viewannotation.annotationAnchor import com.mapbox.maps.viewannotation.geometry import com.mapbox.maps.viewannotation.viewAnnotationOptions +import com.mbta.tid.mbta_app.android.location.LocationDataManager +import com.mbta.tid.mbta_app.android.location.ViewportProvider import com.mbta.tid.mbta_app.android.state.getRailRouteShapes import com.mbta.tid.mbta_app.android.state.getStopMapData import com.mbta.tid.mbta_app.android.util.LazyObjectQueue -import com.mbta.tid.mbta_app.android.util.MapAnimationDefaults -import com.mbta.tid.mbta_app.android.util.ViewportSnapshot -import com.mbta.tid.mbta_app.android.util.followPuck -import com.mbta.tid.mbta_app.android.util.isFollowingPuck import com.mbta.tid.mbta_app.android.util.rememberPrevious import com.mbta.tid.mbta_app.android.util.timer import com.mbta.tid.mbta_app.android.util.toPoint @@ -69,15 +69,17 @@ import com.mbta.tid.mbta_app.model.response.GlobalResponse import com.mbta.tid.mbta_app.model.response.StopMapResponse import io.github.dellisd.spatialk.geojson.Position import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.map @OptIn(MapboxExperimental::class, ExperimentalPermissionsApi::class) @Composable fun HomeMapView( modifier: Modifier = Modifier, - mapViewportState: MapViewportState, globalResponse: GlobalResponse?, alertsData: AlertsStreamDataResponse?, lastNearbyTransitLocation: Position?, + locationDataManager: LocationDataManager, + viewportProvider: ViewportProvider, currentNavEntry: NavBackStackEntry?, handleStopNavigation: (String) -> Unit, vehiclesData: List, @@ -87,9 +89,6 @@ fun HomeMapView( val previousNavEntry: NavBackStackEntry? = rememberPrevious(current = currentNavEntry) val layerManager = remember { LazyObjectQueue() } - val locationPermissionState = - rememberPermissionState(permission = android.Manifest.permission.ACCESS_FINE_LOCATION) - var savedNearbyViewport: ViewportSnapshot? by rememberSaveable { mutableStateOf(null) } var selectedStop by remember { mutableStateOf(null) } val railRouteShapes = getRailRouteShapes() @@ -138,13 +137,7 @@ fun HomeMapView( fun positionViewportToStop() { val stop = selectedStop ?: return - val stopFeature = stopSourceData?.features()?.find { it.id().equals(stop.id) } - val stopPoint = - stopFeature?.geometry() as? Point ?: Point.fromLngLat(stop.longitude, stop.latitude) - mapViewportState.easeTo( - cameraOptions = CameraOptions.Builder().center(stopPoint).build(), - animationOptions = MapAnimationDefaults.options - ) + viewportProvider.animateTo(stop.position.toMapbox()) } fun updateDisplayedRoutesBasedOnStop() { @@ -215,14 +208,13 @@ fun HomeMapView( previousNavEntry?.destination?.route?.contains("NearbyTransit") == true && currentNavEntry?.destination?.route?.contains("StopDetails") == true ) { - savedNearbyViewport = ViewportSnapshot(mapViewportState) + viewportProvider.saveNearbyTransitViewport() } else if ( previousNavEntry?.destination?.route?.contains("StopDetails") == true && currentNavEntry?.destination?.route?.contains("NearbyTransit") == true ) { refreshRouteLineSource() - savedNearbyViewport?.restoreOn(mapViewportState) - savedNearbyViewport = null + viewportProvider.restoreNearbyTransitViewport() } } @@ -236,6 +228,15 @@ fun HomeMapView( selectedStop = globalResponse?.stops?.get(stopId) } + val locationPermissions = locationDataManager.rememberPermissions() + + val cameraZoomFlow = + remember(viewportProvider.cameraStateFlow) { + viewportProvider.cameraStateFlow.map { it.zoom } + } + val zoomLevel by + cameraZoomFlow.collectAsState(initial = ViewportProvider.Companion.Defaults.zoom) + Box(modifier) { MapboxMap( Modifier.fillMaxSize(), @@ -245,7 +246,8 @@ fun HomeMapView( layerManager.run { addLayers(if (isDarkMode) ColorPalette.dark else ColorPalette.light) } - } + }, + onCameraChanged = { viewportProvider.updateCameraState(it.cameraState) } ), gesturesSettings = GesturesSettings { @@ -260,7 +262,7 @@ fun HomeMapView( }, compass = {}, scaleBar = {}, - mapViewportState = mapViewportState, + mapViewportState = viewportProvider.viewport, style = { MapStyle(style = if (isDarkMode) Style.DARK else Style.LIGHT) } ) { LaunchedEffect(currentNavEntry) { handleNavChange() } @@ -282,9 +284,51 @@ fun HomeMapView( val context = LocalContext.current + val locationProvider = remember { PassthroughLocationProvider() } + + LaunchedEffect(locationDataManager) { + locationDataManager.currentLocation.collect { location -> + if (location != null) { + locationProvider.sendLocation( + Point.fromLngLat(location.longitude, location.latitude) + ) + } + } + } + MapEffect(true) { map -> - mapViewportState.followPuck() map.mapboxMap.addOnMapClickListener { point -> handleStopClick(map, point) } + map.location.setLocationProvider(locationProvider) + } + + MapEffect(locationDataManager.hasPermission) { map -> + if (locationDataManager.hasPermission && viewportProvider.isDefault()) { + viewportProvider.follow( + DefaultViewportTransitionOptions.Builder().maxDurationMs(0).build() + ) + layerManager.run { resetPuckPosition() } + } + } + + LifecycleStartEffect(Unit) { + locationPermissions.launchMultiplePermissionRequest() + onStopOrDispose {} + } + + LifecycleStartEffect(Unit) { + onStopOrDispose { viewportProvider.saveCurrentViewport() } + } + + DisposableMapEffect { map -> + val listener = ManuallyCenteringListener(viewportProvider) + map.gestures.addOnMoveListener(listener) + map.gestures.addOnScaleListener(listener) + map.gestures.addOnShoveListener(listener) + onDispose { + map.gestures.removeOnMoveListener(listener) + map.gestures.removeOnScaleListener(listener) + map.gestures.removeOnShoveListener(listener) + } } MapEffect { map -> @@ -292,7 +336,7 @@ fun HomeMapView( layerManager.`object` = MapLayerManager(map.mapboxMap, context) } - if (!mapViewportState.isFollowingPuck && lastNearbyTransitLocation != null) { + if (!viewportProvider.isFollowingPuck && lastNearbyTransitLocation != null) { CircleAnnotation( point = lastNearbyTransitLocation.toPoint(), circleColorString = "#ba75c7", @@ -309,6 +353,7 @@ fun HomeMapView( annotationAnchor { anchor(ViewAnnotationAnchor.CENTER) } allowOverlap(true) allowOverlapWithPuck(true) + visible(zoomLevel >= StopLayerGenerator.stopZoomThreshold) } ) { VehiclePuck(vehicle = vehicle, route = route) @@ -316,12 +361,9 @@ fun HomeMapView( } } - if (!mapViewportState.isFollowingPuck || !locationPermissionState.status.isGranted) { + if (!viewportProvider.isFollowingPuck) { RecenterButton( - onClick = { - locationPermissionState.launchPermissionRequest() - mapViewportState.followPuck() - }, + onClick = { viewportProvider.follow() }, Modifier.align(Alignment.TopEnd).padding(16.dp) ) } diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/ManuallyCenteringListener.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/ManuallyCenteringListener.kt new file mode 100644 index 000000000..f88997f10 --- /dev/null +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/ManuallyCenteringListener.kt @@ -0,0 +1,42 @@ +package com.mbta.tid.mbta_app.android.map + +import com.mapbox.android.gestures.MoveGestureDetector +import com.mapbox.android.gestures.ShoveGestureDetector +import com.mapbox.android.gestures.StandardScaleGestureDetector +import com.mapbox.maps.plugin.gestures.OnMoveListener +import com.mapbox.maps.plugin.gestures.OnScaleListener +import com.mapbox.maps.plugin.gestures.OnShoveListener +import com.mbta.tid.mbta_app.android.location.ViewportProvider + +class ManuallyCenteringListener(private val viewportProvider: ViewportProvider) : + OnMoveListener, OnScaleListener, OnShoveListener { + override fun onMove(detector: MoveGestureDetector) = false + + override fun onMoveBegin(detector: MoveGestureDetector) { + viewportProvider.setIsManuallyCentering(true) + } + + override fun onMoveEnd(detector: MoveGestureDetector) { + viewportProvider.setIsManuallyCentering(false) + } + + override fun onScale(detector: StandardScaleGestureDetector) {} + + override fun onScaleBegin(detector: StandardScaleGestureDetector) { + viewportProvider.setIsManuallyCentering(true) + } + + override fun onScaleEnd(detector: StandardScaleGestureDetector) { + viewportProvider.setIsManuallyCentering(false) + } + + override fun onShove(detector: ShoveGestureDetector) {} + + override fun onShoveBegin(detector: ShoveGestureDetector) { + viewportProvider.setIsManuallyCentering(true) + } + + override fun onShoveEnd(detector: ShoveGestureDetector) { + viewportProvider.setIsManuallyCentering(false) + } +} diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/MapLayerManager.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/MapLayerManager.kt index 13cf9fea3..143c909ea 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/MapLayerManager.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/MapLayerManager.kt @@ -49,6 +49,12 @@ class MapLayerManager(val map: MapboxMap, context: Context) { } } + fun resetPuckPosition() { + if (map.styleLayerExists("puck")) { + map.moveStyleLayer("puck", null) + } + } + private fun updateSourceData(sourceId: String, data: FeatureCollection) { if (map.styleSourceExists(sourceId)) { map.setStyleGeoJSONSourceData( diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/PassthroughLocationProvider.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/PassthroughLocationProvider.kt new file mode 100644 index 000000000..a252c5b04 --- /dev/null +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/map/PassthroughLocationProvider.kt @@ -0,0 +1,23 @@ +package com.mbta.tid.mbta_app.android.map + +import com.mapbox.geojson.Point +import com.mapbox.maps.plugin.locationcomponent.LocationConsumer +import com.mapbox.maps.plugin.locationcomponent.LocationProvider + +class PassthroughLocationProvider : LocationProvider { + private val consumers = mutableSetOf() + + fun sendLocation(location: Point) { + for (consumer in consumers) { + consumer.onLocationUpdated(location) + } + } + + override fun registerLocationConsumer(locationConsumer: LocationConsumer) { + consumers.add(locationConsumer) + } + + override fun unRegisterLocationConsumer(locationConsumer: LocationConsumer) { + consumers.remove(locationConsumer) + } +} 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 070ab4b70..dfad4bd2e 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 @@ -8,9 +8,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -35,14 +33,14 @@ fun NearbyTransitView( modifier: Modifier = Modifier, alertData: AlertsStreamDataResponse?, globalResponse: GlobalResponse?, - targetLocation: Position, + targetLocation: Position?, setLastLocation: (Position) -> Unit, onOpenStopDetails: (String, StopDetailsFilter?) -> Unit, ) { var nearby: NearbyStaticData? = getNearby( globalResponse, - Coordinate(latitude = targetLocation.latitude, longitude = targetLocation.longitude), + targetLocation?.let { Coordinate(latitude = it.latitude, longitude = it.longitude) }, setLastLocation ) val now = timer(updateInterval = 5.seconds) @@ -63,16 +61,20 @@ fun NearbyTransitView( now, pinnedRoutes ) { - nearby?.withRealtimeInfo( - globalData = globalResponse, - sortByDistanceFrom = targetLocation, - schedules, - predictions, - alertData, - now, - pinnedRoutes.orEmpty(), - useTripHeadsigns = false, - ) + if (targetLocation != null) { + nearby?.withRealtimeInfo( + globalData = globalResponse, + sortByDistanceFrom = targetLocation, + schedules, + predictions, + alertData, + now, + pinnedRoutes.orEmpty(), + useTripHeadsigns = false, + ) + } else { + null + } } Column(modifier) { 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 a7028dc23..1ec79b0fd 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 @@ -31,11 +31,13 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.mapbox.maps.MapboxExperimental -import com.mapbox.maps.extension.compose.animation.viewport.MapViewportState import com.mbta.tid.mbta_app.android.SheetRoutes import com.mbta.tid.mbta_app.android.component.DragHandle +import com.mbta.tid.mbta_app.android.location.LocationDataManager +import com.mbta.tid.mbta_app.android.location.ViewportProvider import com.mbta.tid.mbta_app.android.map.HomeMapView import com.mbta.tid.mbta_app.android.nearbyTransit.NearbyTransitView +import com.mbta.tid.mbta_app.android.util.toPosition import com.mbta.tid.mbta_app.model.StopDetailsDepartures import com.mbta.tid.mbta_app.model.StopDetailsFilter import com.mbta.tid.mbta_app.model.Vehicle @@ -45,22 +47,22 @@ import com.mbta.tid.mbta_app.model.response.GlobalResponse import com.mbta.tid.mbta_app.model.response.VehiclesStreamDataResponse import com.mbta.tid.mbta_app.repositories.IVehiclesRepository import io.github.dellisd.spatialk.geojson.Position +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) -data class NearbyTransit -@OptIn(ExperimentalMaterial3Api::class, MapboxExperimental::class) -constructor( +data class NearbyTransit( val alertData: AlertsStreamDataResponse?, val globalResponse: GlobalResponse?, - val targetLocation: Position, - val mapCenter: Position, var lastNearbyTransitLocation: Position?, val scaffoldState: BottomSheetScaffoldState, - val mapViewportState: MapViewportState, + val locationDataManager: LocationDataManager, + val viewportProvider: ViewportProvider, ) -@OptIn(ExperimentalMaterial3Api::class, MapboxExperimental::class) +@OptIn(ExperimentalMaterial3Api::class, MapboxExperimental::class, FlowPreview::class) @Composable fun NearbyTransitPage( modifier: Modifier = Modifier, @@ -193,10 +195,33 @@ fun NearbyTransitPage( stopDetailsFilter = null } + var targetLocation by remember { mutableStateOf(null) } + LaunchedEffect(nearbyTransit.viewportProvider) { + nearbyTransit.viewportProvider.cameraStateFlow + .debounce(0.5.seconds) + .collect { + // since this LaunchedEffect is cancelled when not on the + // nearby transit page, we don't need to check + targetLocation = it.center.toPosition() + } + } + LaunchedEffect(nearbyTransit.viewportProvider.isManuallyCentering) { + if (nearbyTransit.viewportProvider.isManuallyCentering) { + // TODO reset view model + targetLocation = null + } + } + LaunchedEffect(nearbyTransit.viewportProvider.isFollowingPuck) { + if (nearbyTransit.viewportProvider.isFollowingPuck) { + // TODO reset view model + targetLocation = null + } + } + NearbyTransitView( alertData = nearbyTransit.alertData, globalResponse = nearbyTransit.globalResponse, - targetLocation = nearbyTransit.mapCenter, + targetLocation = targetLocation, setLastLocation = { nearbyTransit.lastNearbyTransitLocation = it }, onOpenStopDetails = { stopId, filter -> navController.navigate( @@ -218,10 +243,11 @@ fun NearbyTransitPage( ) { sheetPadding -> HomeMapView( Modifier.padding(sheetPadding), - nearbyTransit.mapViewportState, globalResponse = nearbyTransit.globalResponse, alertsData = nearbyTransit.alertData, lastNearbyTransitLocation = nearbyTransit.lastNearbyTransitLocation, + locationDataManager = nearbyTransit.locationDataManager, + viewportProvider = nearbyTransit.viewportProvider, currentNavEntry = currentNavEntry, handleStopNavigation = ::handleStopNavigation, vehiclesData = vehiclesData, diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getNearby.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getNearby.kt index 8938f75b4..1c673c916 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getNearby.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/state/getNearby.kt @@ -15,14 +15,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.koin.compose.koinInject class NearbyViewModel( private val nearbyRepository: INearbyRepository, private val globalResponse: GlobalResponse?, - private val location: Coordinate, + private val location: Coordinate?, setLastLocation: (Position) -> Unit ) : ViewModel() { private val _nearbyResponse = MutableStateFlow(null) @@ -32,8 +31,8 @@ class NearbyViewModel( init { CoroutineScope(Dispatchers.IO).launch { nearbyResponse.collect { - if (globalResponse != null) { - getNearby(globalResponse) + if (globalResponse != null && location != null) { + getNearby(globalResponse, location) setLastLocation( Position(latitude = location.latitude, longitude = location.longitude) ) @@ -42,7 +41,7 @@ class NearbyViewModel( } } - suspend fun getNearby(globalResponse: GlobalResponse) { + suspend fun getNearby(globalResponse: GlobalResponse, location: Coordinate) { when (val data = nearbyRepository.getNearby(globalResponse, location)) { is ApiResult.Ok -> _nearbyResponse.emit(data.data) is ApiResult.Error -> TODO("handle errors") @@ -58,7 +57,7 @@ class NearbyViewModel( @Composable fun getNearby( globalResponse: GlobalResponse?, - location: Coordinate, + location: Coordinate?, setLastLocation: (Position) -> Unit, nearbyRepository: INearbyRepository = koinInject() ): NearbyStaticData? { diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/Locals.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/Locals.kt new file mode 100644 index 000000000..8ba21f7dd --- /dev/null +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/Locals.kt @@ -0,0 +1,13 @@ +package com.mbta.tid.mbta_app.android.util + +import android.app.Activity +import androidx.compose.runtime.staticCompositionLocalOf +import com.google.android.gms.location.FusedLocationProviderClient + +val LocalActivity = + staticCompositionLocalOf { throw IllegalStateException("no activity") } + +val LocalLocationClient = + staticCompositionLocalOf { + throw IllegalStateException("no location client") + } diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/MapboxUtil.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/MapboxUtil.kt index e4a5fcc76..8329ef232 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/MapboxUtil.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/util/MapboxUtil.kt @@ -7,6 +7,8 @@ import com.mapbox.maps.plugin.viewport.ViewportStatus import com.mapbox.maps.plugin.viewport.data.FollowPuckViewportStateOptions import com.mapbox.maps.plugin.viewport.state.FollowPuckViewportState import io.github.dellisd.spatialk.geojson.Position +import kotlin.math.pow +import kotlin.math.round @OptIn(MapboxExperimental::class) fun MapViewportState.followPuck(zoom: Double? = null) { @@ -31,6 +33,15 @@ val MapViewportState.isFollowingPuck: Boolean is ViewportStatus.Transition -> status.toState is FollowPuckViewportState } +fun Double.roundedTo(places: Int): Double { + val divisor = 10.0.pow(places) + return round(this * divisor) / divisor +} + +fun Point.isRoughlyEqualTo(other: Point) = + this.latitude().roundedTo(6) == other.latitude().roundedTo(6) && + this.longitude().roundedTo(6) == other.longitude().roundedTo(6) + fun Point.toPosition() = Position(longitude = longitude(), latitude = latitude()) fun Position.toPoint(): Point = Point.fromLngLat(longitude, latitude) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3cd3382cf..244dd8e8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ accompanist = "0.36.0" androidx-activityCompose = "1.9.1" androidx-navigationCompose = "2.8.1" +androidx-test-monitor = "1.7.2" +androidx-test-rules = "1.6.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 @@ -20,6 +22,7 @@ mapboxTurf = "6.15.0" mokkery = "2.3.0" okhttp = "4.12.0" okio = "3.9.1" +playServicesLocation = "21.3.0" sentry = "0.9.0" skie = "0.9.3" spatialk = "0.3.0" @@ -30,6 +33,8 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigationCompose" } +androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-test-monitor" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material3 = { module = "androidx.compose.material3:material3" } @@ -62,6 +67,7 @@ mapbox-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = " okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } +playServices-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } sentry = { module = "io.sentry:sentry-kotlin-multiplatform", version.ref = "sentry" } skie-configuration-annotations = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } spatialk-geojson = { module = "io.github.dellisd.spatialk:geojson", version.ref = "spatialk" } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Vehicle.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Vehicle.kt index 344f396a1..df9f70919 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Vehicle.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Vehicle.kt @@ -1,5 +1,6 @@ package com.mbta.tid.mbta_app.model +import io.github.dellisd.spatialk.geojson.Position import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -18,6 +19,8 @@ data class Vehicle( @SerialName("stop_id") val stopId: String?, @SerialName("trip_id") val tripId: String?, ) : BackendObject { + val position = Position(latitude = latitude, longitude = longitude) + @Serializable enum class CurrentStatus { @SerialName("incoming_at") IncomingAt,