diff --git a/gradle.properties b/gradle.properties index 0ed08c6d..42dfc0bb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,9 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX # (not needed, since no requested libraries are pre-AndroidX) -android.enableJetifier=false +#android.enableJetifier=false +# This is needed for the navigation SDK library. It needs the recycler view. +android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official diff --git a/navigation-app/build.gradle.kts b/navigation-app/build.gradle.kts index 00cda1fc..c96c5e49 100644 --- a/navigation-app/build.gradle.kts +++ b/navigation-app/build.gradle.kts @@ -83,7 +83,6 @@ dependencies { implementation(libs.play.services.location) -// testImplementation(libs.robolectric) testImplementation(libs.androidx.core) testImplementation(libs.truth) @@ -94,7 +93,6 @@ dependencies { // Accompanist permission helper implementation(libs.accompanist.permissions) - } secrets { diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt index bc8c9f55..1bbb6ae0 100644 --- a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.navigation.NavigationApi import com.google.maps.android.compose.navigation.ui.theme.AndroidmapscomposeTheme import kotlinx.coroutines.launch @@ -30,7 +31,7 @@ val defaultLocation = LatLng(39.9828503662161, -105.71835147137016) @OptIn(ExperimentalPermissionsApi::class) class MainActivity : ComponentActivity() { - private val myViewModel: MainViewModel by viewModels { MainViewModel.Factory } + private val myViewModel: NavigationViewModel by viewModels { NavigationViewModel.Factory } override fun onResume() { super.onResume() @@ -40,6 +41,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + NavigationApi.getNavigator(this, myViewModel) + setContent { val context = LocalContext.current val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainViewModel.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainViewModel.kt deleted file mode 100644 index 81c4c483..00000000 --- a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.google.maps.android.compose.navigation - - -import android.Manifest -import android.annotation.SuppressLint -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras -import com.google.android.gms.maps.model.LatLng -import com.google.android.libraries.navigation.NavigationApi -import com.google.android.libraries.places.api.net.PlacesClient -import com.google.maps.android.compose.navigation.repositories.LocationProvider -import com.google.maps.android.compose.navigation.repositories.PermissionChecker -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class MainViewModel( - private val placesClient: PlacesClient, - private val locationProvider: LocationProvider, - private val permissionChecker: PermissionChecker, -) : ViewModel() { - - private fun String.isPermissionGranted() = permissionChecker.isGranted(this) - - private val _location = MutableStateFlow(null) - val location = _location.asStateFlow().onStart { - requestLocation() - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = null - ) - - private val _uiEvent = MutableSharedFlow() - val uiEvent: SharedFlow = _uiEvent.asSharedFlow() - - private val _hasLocationPermission = MutableStateFlow(false) - - init { - viewModelScope.launch { - _hasLocationPermission.collect() { - if (it) { - requestLocation() - } - } - } - } - - @SuppressLint("MissingPermission") - fun requestLocation() { - viewModelScope.launch { - if (Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted()) { - val location = locationProvider.getLastLocation()?.toLatLng() - if (location != null) { - _location.value = location - } - } else { - _uiEvent.emit(UiEvent.RequestLocationPermission) - } - } - } - - fun checkLocationPermission() { - viewModelScope.launch { - _hasLocationPermission.value = Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted() - } - } - - companion object { - val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: Class, - extras: CreationExtras - ): T { - val application = - checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as NavigationApplication - - return MainViewModel( - placesClient = application.placesClient, - locationProvider = LocationProvider(application.applicationContext), - permissionChecker = PermissionChecker(application.applicationContext) - ) as T - } - } - } -} \ No newline at end of file diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt index 7bb1548d..ec0b9fbc 100644 --- a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt @@ -1,18 +1,31 @@ package com.google.maps.android.compose.navigation +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.libraries.navigation.NavigationView import com.google.maps.android.compose.ComposeMapColorScheme import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.navigation.components.MovableMarker import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState @Composable fun NavigationScreen( @@ -54,6 +67,25 @@ fun NavigationScreen( title = "User location", ) } + + MarkerComposable( + title = "Bigfoot", + state = rememberMarkerState(position = LatLng(39.99932703674056, -105.28152457787887)), + ) { + Box( + modifier = Modifier + .width(48.dp) + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.bigfoot), + contentDescription = "" + ) + } + } } } -} \ No newline at end of file +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt new file mode 100644 index 00000000..0d644a09 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt @@ -0,0 +1,239 @@ +package com.google.maps.android.compose.navigation + + +import android.Manifest +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.navigation.NavigationApi +import com.google.android.libraries.navigation.NavigationApi.NavigatorListener +import com.google.android.libraries.navigation.Navigator +import com.google.android.libraries.navigation.RoutingOptions +import com.google.android.libraries.navigation.SimulationOptions +import com.google.android.libraries.navigation.Waypoint +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.maps.android.compose.navigation.repositories.LocationProvider +import com.google.maps.android.compose.navigation.repositories.PermissionChecker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.libraries.navigation.ListenableResultFuture +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import com.google.maps.android.compose.navigation.BuildConfig + +class NavigationViewModel( + private val placesClient: PlacesClient, + private val locationProvider: LocationProvider, + private val permissionChecker: PermissionChecker, +) : ViewModel(), NavigatorListener { + + private fun String.isPermissionGranted() = permissionChecker.isGranted(this) + + private val _location = MutableStateFlow(null) + val location = _location.asStateFlow().onStart { + requestLocation() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + + private val _uiEvent = MutableSharedFlow() + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() + + private val _hasLocationPermission = MutableStateFlow(false) + + private var navigator: Navigator? = null + + init { + viewModelScope.launch { + _hasLocationPermission.collect() { + if (it) { + requestLocation() + } + } + } + } + + @SuppressLint("MissingPermission") + fun requestLocation() { + viewModelScope.launch { + if (Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted()) { + val location = locationProvider.getLastLocation()?.toLatLng() + if (location != null) { + _location.value = location + } + } else { + _uiEvent.emit(UiEvent.RequestLocationPermission) + } + } + } + + fun checkLocationPermission() { + viewModelScope.launch { + _hasLocationPermission.value = Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted() + } + } + + companion object { + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + val application = + checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as NavigationApplication + + return NavigationViewModel( + placesClient = application.placesClient, + locationProvider = LocationProvider(application.applicationContext), + permissionChecker = PermissionChecker(application.applicationContext) + ) as T + } + } + } + + private fun navigateToPlace(placeId: String, routingOptions: RoutingOptions) { + val localNavigator = checkNotNull(navigator) + + viewModelScope.launch { + try { + val destination = withContext(Dispatchers.IO) { + Waypoint.builder().setPlaceIdString(placeId).build() + } + + val cancellationTokenSource = CancellationTokenSource() + val routeStatusFuture = localNavigator.setDestination(destination, routingOptions) + + suspendCoroutine { continuation -> + val callback = + ListenableResultFuture.OnResultListener { status -> + fun onFailure(message: String) { + continuation.resumeWithException(Exception(message)) + } + + when (status) { + Navigator.RouteStatus.OK -> { + // // Hide the toolbar to maximize the navigation UI. + // if (getActionBar() != null) { + // getActionBar().hide() + // } + // // Enable voice audio guidance (through the device speaker). + // navigator.setAudioGuidance( + // Navigator.AudioGuidance.VOICE_ALERTS_AND_GUIDANCE + // ) + // Simulate vehicle progress along the route for demo/debug builds. + if (BuildConfig.DEBUG) { + localNavigator.simulator.simulateLocationsAlongExistingRoute( + SimulationOptions().speedMultiplier(5f) + ) + } + + // Start turn-by-turn guidance along the current route. + localNavigator.startGuidance() + + continuation.resume(Unit) + } + + Navigator.RouteStatus.NO_ROUTE_FOUND -> onFailure("Error starting navigation: No route found") + Navigator.RouteStatus.NETWORK_ERROR -> onFailure("Error starting navigation: Network error") + Navigator.RouteStatus.QUOTA_CHECK_FAILED -> onFailure("Error starting navigation: Quota check failed") + Navigator.RouteStatus.ROUTE_CANCELED -> onFailure("Error starting navigation: Route canceled") + Navigator.RouteStatus.LOCATION_DISABLED -> onFailure("Error starting navigation: Location disabled") + Navigator.RouteStatus.LOCATION_UNKNOWN -> onFailure("Error starting navigation: Location unknown") + Navigator.RouteStatus.WAYPOINT_ERROR -> onFailure("Error starting navigation: Waypoint error") + + else -> onFailure("Error starting navigation: $status") + } + } + + routeStatusFuture.setOnResultListener(callback) + } + } catch (e: Waypoint.UnsupportedPlaceIdException) { + withContext(Dispatchers.Main) { + displayMessage("Error starting navigation: Place ID is not supported.") + } + } + } + } + + override fun onNavigatorReady(navigator: Navigator?) { + displayMessage("navigator ready") + + val chautauquaDinningHall = "ChIJ9zb1-0bsa4cRcpW_h34lLBU" + + this.navigator = navigator ?: error("Navigator is null") + + /* + // Optional. Disable the guidance notifications and shut down the app + // and background service when the user closes the app. + // mNavigator.setTaskRemovedBehavior(Navigator.TaskRemovedBehavior.QUIT_SERVICE) + + // Optional. Set the last digit of the car's license plate to get + // route restrictions for supported countries. + // mNavigator.setLicensePlateRestrictionInfo(getLastDigit(), "BZ"); + + // Set the camera to follow the device location with 'TILTED' driving view. + mNavFragment.getCamera().followMyLocation(Camera.Perspective.TILTED); + + // Set the travel mode (DRIVING, WALKING, CYCLING, TWO_WHEELER, or TAXI). + mRoutingOptions = new RoutingOptions(); + mRoutingOptions.travelMode(RoutingOptions.TravelMode.DRIVING); + + // Navigate to a place, specified by Place ID. + navigateToPlace(SYDNEY_OPERA_HOUSE, mRoutingOptions); + */ + + val routingOptions = RoutingOptions().apply { + travelMode(RoutingOptions.TravelMode.DRIVING) + } + + navigateToPlace(chautauquaDinningHall, routingOptions) + } + + override fun onError(@NavigationApi.ErrorCode errorCode: Int) { + when (errorCode) { + NavigationApi.ErrorCode.NOT_AUTHORIZED -> displayMessage( + "Error loading Navigation SDK: Your API key is " + + "invalid or not authorized to use the Navigation SDK." + ) + + NavigationApi.ErrorCode.TERMS_NOT_ACCEPTED -> displayMessage( + "Error loading Navigation SDK: User did not accept " + + "the Navigation Terms of Use." + ) + + NavigationApi.ErrorCode.NETWORK_ERROR -> displayMessage("Error loading Navigation SDK: Network error.") + NavigationApi.ErrorCode.LOCATION_PERMISSION_MISSING -> displayMessage( + "Error loading Navigation SDK: Location permission " + + "is missing." + ) + + else -> displayMessage("Error loading Navigation SDK: $errorCode") + } + } + + private fun displayMessage(message: String) { + Log.w("NavigationViewModel", message) + viewModelScope.launch { + _uiEvent.emit(UiEvent.ShowSnackbar(message)) + } + } +} + diff --git a/navigation-app/src/main/res/drawable/bigfoot.png b/navigation-app/src/main/res/drawable/bigfoot.png new file mode 100644 index 00000000..1f7cc629 Binary files /dev/null and b/navigation-app/src/main/res/drawable/bigfoot.png differ