Distance settings

This commit is contained in:
Dimitris
2026-03-03 16:34:03 +01:00
parent e1af3e19fa
commit 11e9dbb21e
39 changed files with 753 additions and 128 deletions

View File

@@ -102,7 +102,7 @@ class RouteModelTest {
} else {
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
}
assertEquals(stepData.leftStepDistance, 300.0, 1.0)
assertEquals(stepData.leftStepDistance, 301.0, 1.0)
}
@Test
@@ -192,7 +192,7 @@ class RouteModelTest {
val location: Location = location( 11.584578, 48.183653)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()) )
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 650.0, 0.1)
assertEquals(step.leftStepDistance, 645.0, 1.0)
}
@Test
@@ -200,6 +200,6 @@ class RouteModelTest {
val location: Location = location( 11.578911, 48.185565)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()) )
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance , 30.0, 1.0)
assertEquals(step.leftStepDistance , 34.0, 1.0)
}
}

View File

@@ -43,7 +43,7 @@ class DeviceLocationManager(
* Only processes location if car location hardware is not being used.
*/
private val locationListener: LocationListenerCompat = LocationListenerCompat { location ->
if (location != null && shouldUseDeviceLocation) {
if (shouldUseDeviceLocation) {
onLocationUpdate(location)
}
}

View File

@@ -1,7 +1,6 @@
package com.kouros.navigation.car
import android.Manifest
import android.content.Context
import android.Manifest.permission
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
@@ -11,9 +10,10 @@ import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.connection.CarConnection
import androidx.core.net.toUri
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.NavigationManagerCallback
import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStore
@@ -23,6 +23,8 @@ import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.car.screen.RequestPermissionScreen
import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.GMS_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG
@@ -35,9 +37,7 @@ import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import android.Manifest.permission
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.GMS_CAR_SPEED_PERMISSION
/**
* Main session for Android Auto/Automotive OS navigation.
@@ -65,12 +65,17 @@ class NavigationSession : Session(), NavigationScreen.Listener {
// Manages device GPS location updates
lateinit var deviceLocationManager: DeviceLocationManager
lateinit var navigationManager: NavigationManager
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
*/
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
if (::navigationManager.isInitialized) {
navigationManager.clearNavigationManagerCallback()
}
if (::carSensorManager.isInitialized) {
carSensorManager.cleanup()
}
@@ -178,6 +183,21 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Initializes managers for rendering, sensors, and location.
*/
private fun initializeManagers() {
navigationManager = carContext.getCarService(NavigationManager::class.java)
navigationManager.setNavigationManagerCallback(object : NavigationManagerCallback {
override fun onAutoDriveEnabled() {
// Called when the app should simulate navigation (e.g., for testing)
// Implement your simulation logic here
Log.d("CarApp", "Auto Drive Enabled")
}
override fun onStopNavigation() {
// Called when the user stops navigation in the car screen
Log.d("CarApp", "Stop Navigation Requested")
// Stop turn-by-turn logic and clean up
}
})
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
carSensorManager = CarSensorManager(
@@ -346,6 +366,20 @@ class NavigationSession : Session(), NavigationScreen.Listener {
*/
override fun stopNavigation() {
routeModel.stopNavigation()
navigationManager.navigationEnded()
}
/**
* Start navigation process.
* Called when user starts navigation
*/
override fun startNavigation() {
navigationManager.navigationStarted()
}
override fun updateTrip(trip: Trip) {
Log.d("Trip", trip.toString())
navigationManager.updateTrip(trip)
}
companion object {

View File

@@ -1,6 +1,5 @@
package com.kouros.navigation.car.navigation
import android.location.Location
import android.text.SpannableString
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -26,9 +25,7 @@ import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.location
import java.util.Collections
import java.util.Locale
import com.kouros.navigation.utils.formattedDistance
import java.util.TimeZone
import java.util.concurrent.TimeUnit
@@ -87,24 +84,21 @@ class RouteCarModel() : RouteModel() {
return step
}
fun travelEstimate(carContext: CarContext): TravelEstimate {
fun travelEstimate(carContext: CarContext, distanceMode: Int): TravelEstimate {
val timeLeft = routeCalculator.travelLeftTime()
val timeToDestinationMillis =
TimeUnit.SECONDS.toMillis(timeLeft.toLong())
val leftDistance = routeCalculator.travelLeftDistance() / 1000
val displayUnit = if (leftDistance > 1.0) {
Distance.UNIT_KILOMETERS
} else {
Distance.UNIT_METERS
}
val distance = formattedDistance(distanceMode, routeCalculator.travelLeftDistance())
val arrivalTime = DateTimeWithZone.create(
routeCalculator.arrivalTime(),
TimeZone.getTimeZone("Europe/Berlin")
TimeZone.getDefault()
)
val traffic = (route.routes.first().summary.trafficDelay/60).toInt()
val travelBuilder = TravelEstimate.Builder( // The estimated distance to the destination.
Distance.create(
leftDistance,
displayUnit
distance.first,
distance.second
), // Arrival time at the destination with the destination time zone.
arrivalTime
)
@@ -115,7 +109,7 @@ class RouteCarModel() : RouteModel() {
)
.setRemainingTimeColor(CarColor.GREEN)
.setRemainingDistanceColor(CarColor.BLUE)
.setTripText(CarText.create("$traffic min"))
if (navState.travelMessage.isNotEmpty()) {
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
travelBuilder.setTripText(CarText.create(navState.travelMessage))

View File

@@ -42,6 +42,12 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
R.string.dark_mode
)
)
listBuilder.addItem(
buildRowForScreenTemplate(
DistanceSettings(carContext),
R.string.distance_units
)
)
return ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(

View File

@@ -0,0 +1,84 @@
package com.kouros.navigation.car.screen
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class DistanceSettings(private val carContext: CarContext) : Screen(carContext) {
private var distanceSettings = 0
val settingsViewModel = getSettingsViewModel(carContext)
init {
lifecycleScope.launch {
settingsViewModel.distanceMode.first()
}
}
override fun onGetTemplate(): Template {
distanceSettings = settingsViewModel.distanceMode.value
val templateBuilder = ListTemplate.Builder()
val radioList =
ItemList.Builder()
.addItem(
buildRowForTemplate(
R.string.automaticaly,
)
)
.addItem(
buildRowForTemplate(
R.string.kilometer,
)
)
.addItem(
buildRowForTemplate(
R.string.miles,
)
)
.setOnSelectedListener { index: Int ->
this.onSelected(index)
}
.setSelectedIndex(distanceSettings)
.build()
return templateBuilder
.addSectionedList(
SectionedItemList.create(
radioList,
carContext.getString(R.string.distance_units)
)
)
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.distance_units))
.setStartHeaderAction(Action.BACK)
.build()
)
.build()
}
private fun onSelected(index: Int) {
settingsViewModel.onDistanceModeChanged(index)
}
private fun buildRowForTemplate(title: Int): Row {
return Row.Builder()
.setTitle(carContext.getString(title))
.build()
}
}

View File

@@ -1,6 +1,5 @@
package com.kouros.navigation.car.screen
import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
@@ -18,12 +17,16 @@ import androidx.car.app.model.Distance
import androidx.car.app.model.Header
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.MessageInfo
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Trip
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
@@ -31,12 +34,12 @@ import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
@@ -48,6 +51,10 @@ import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.absoluteValue
/**
* Main screen for car navigation.
* Handles different navigation states and provides corresponding templates.
*/
class NavigationScreen(
carContext: CarContext,
private var surfaceRenderer: SurfaceRenderer,
@@ -60,10 +67,15 @@ class NavigationScreen(
interface Listener {
/** Stops navigation. */
fun stopNavigation()
/** Starts navigation. */
fun startNavigation()
/** Updates trip information. */
fun updateTrip(trip: Trip)
}
val backGroundColor = CarColor.BLUE
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
var recentPlace = Place()
var navigationType = NavigationType.VIEW
@@ -74,18 +86,25 @@ class NavigationScreen(
val observerManager = NavigationObserverManager(navigationViewModel, this)
val repository = getSettingsRepository(carContext)
var distanceMode = 0
init {
observerManager.attachAllObservers(this)
lifecycleScope.launch {
getSettingsViewModel(carContext).routingEngine.first()
getSettingsViewModel(carContext).recentPlaces.first()
distanceMode = repository.distanceModeFlow.first()
}
}
// NavigationObserverCallback implementations
/**
* Handles the received route string.
* Starts navigation and invalidates the screen.
*/
override fun onRouteReceived(route: String) {
if (route.isNotEmpty()) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
@@ -94,26 +113,45 @@ class NavigationScreen(
getSettingsViewModel(carContext).onLastRouteChanged(route)
}
surfaceRenderer.setRouteData()
listener.startNavigation()
invalidate()
}
}
/**
* Checks if navigation is currently active.
*/
override fun isNavigating(): Boolean = routeModel.isNavigating()
/**
* Handles the received recent place.
* Updates the navigation type to RECENT and invalidates the screen.
*/
override fun onRecentPlaceReceived(place: Place) {
recentPlace = place
navigationType = NavigationType.RECENT
invalidate()
}
/**
* Handles received traffic data and updates the surface renderer.
*/
override fun onTrafficReceived(traffic: Map<String, String>) {
surfaceRenderer.setTrafficData(traffic)
}
/**
* Handles the received place search result.
* Navigates to the specified place.
*/
override fun onPlaceSearchResultReceived(place: Place) {
navigateToPlace(place)
}
/**
* Handles received speed camera data.
* Updates the surface renderer with the camera locations.
*/
override fun onSpeedCamerasReceived(cameras: List<Elements>) {
speedCameras = cameras
val coordinates = mutableListOf<List<Double>>()
@@ -124,15 +162,27 @@ class NavigationScreen(
surfaceRenderer.speedCamerasData.value = speedData
}
/**
* Handles received maximum speed data and updates the surface renderer.
*/
override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed
}
/**
* Invalidates the screen.
*/
override fun invalidateScreen() {
invalidate()
}
/**
* Returns the appropriate template based on the current navigation state.
*/
override fun onGetTemplate(): Template {
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
val actionStripBuilder = createActionStripBuilder()
return when (navigationType) {
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
@@ -143,21 +193,28 @@ class NavigationScreen(
}
}
/**
* Creates and returns a NavigationTemplate for the active navigation state.
*/
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
actionStripBuilder.addAction(
stopAction()
)
updateTrip()
return NavigationTemplate.Builder()
.setNavigationInfo(
getRoutingInfo()
)
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext))
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext, distanceMode))
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setBackgroundColor(backGroundColor)
.build()
}
/**
* Creates and returns a template for the default view state.
*/
private fun navigationViewTemplate(actionStripBuilder: ActionStrip.Builder): Template {
return NavigationTemplate.Builder()
.setBackgroundColor(CarColor.SECONDARY)
@@ -167,6 +224,9 @@ class NavigationScreen(
}
/**
* Creates and returns a template for the arrival or end state of navigation.
*/
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
if (routeModel.navState.arrived) {
val timer = object : CountDownTimer(8000, 1000) {
@@ -189,6 +249,9 @@ class NavigationScreen(
}
/**
* Creates and returns a NavigationTemplate specifically for when the destination is reached.
*/
fun navigationArrivedTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
var street = ""
if (routeModel.navState.destination.street != null) {
@@ -217,6 +280,9 @@ class NavigationScreen(
.build()
}
/**
* Creates and returns a template showing recent places or destinations.
*/
fun navigationRecentPlaceTemplate(): Template {
val messageTemplate = MessageTemplate.Builder(
recentPlace.name + "\n"
@@ -242,6 +308,9 @@ class NavigationScreen(
return builder.build()
}
/**
* Creates and returns a template for when the route is being recalculated.
*/
fun navigationRerouteTemplate(actionStripBuilder: ActionStrip.Builder): Template {
return NavigationTemplate.Builder()
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
@@ -250,19 +319,15 @@ class NavigationScreen(
.build()
}
/**
* Builds and returns RoutingInfo based on the current step and distance.
*/
fun getRoutingInfo(): RoutingInfo {
var currentDistance = routeModel.routeCalculator.leftStepDistance()
val displayUnit = if (currentDistance > 1000.0) {
currentDistance /= 1000.0
Distance.UNIT_KILOMETERS
} else {
Distance.UNIT_METERS
}
val distance = formattedDistance(distanceMode, routeModel.routeCalculator.leftStepDistance())
val routingInfo = RoutingInfo.Builder()
.setCurrentStep(
routeModel.currentStep(carContext = carContext),
Distance.create(currentDistance, displayUnit)
Distance.create(distance.first, distance.second)
)
if (routeModel.navState.nextStep) {
val nextStep = routeModel.nextStep(carContext = carContext)
@@ -271,6 +336,9 @@ class NavigationScreen(
return routingInfo.build()
}
/**
* Creates an ActionStrip builder with common search and settings actions.
*/
private fun createActionStripBuilder(): ActionStrip.Builder {
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
actionStripBuilder.addAction(
@@ -282,6 +350,9 @@ class NavigationScreen(
return actionStripBuilder
}
/**
* Creates an ActionStrip builder for map-related actions like zoom and pan.
*/
private fun mapActionStripBuilder(): ActionStrip.Builder {
val actionStripBuilder = ActionStrip.Builder()
.addAction(zoomPlus())
@@ -295,6 +366,9 @@ class NavigationScreen(
return actionStripBuilder
}
/**
* Creates a stop navigation action.
*/
private fun stopAction(): Action {
return Action.Builder()
.setTitle(carContext.getString(R.string.stop_action_title))
@@ -313,6 +387,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to start navigation to a specific place.
*/
private fun navigateAction(): Action {
navigationType = NavigationType.NAVIGATION
return Action.Builder()
@@ -338,6 +415,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to close the current view or template.
*/
private fun closeAction(): Action {
return Action.Builder()
.setIcon(
@@ -357,6 +437,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to start the search screen.
*/
private fun searchAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.search_48px))
@@ -366,6 +449,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to start the settings screen.
*/
private fun settingsAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.settings_48px))
@@ -375,6 +461,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to zoom in on the map.
*/
private fun zoomPlus(): Action {
return Action.Builder()
.setIcon(
@@ -392,6 +481,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to zoom out on the map.
*/
private fun zoomMinus(): Action {
return Action.Builder()
.setIcon(
@@ -409,6 +501,9 @@ class NavigationScreen(
.build()
}
/**
* Creates an action to enable map panning.
*/
private fun panAction(): Action {
return Action.Builder()
.setIcon(
@@ -426,6 +521,9 @@ class NavigationScreen(
.build()
}
/**
* Pushes the search screen and handles the search result.
*/
private fun startSearchScreen() {
screenManager
.pushForResult(
@@ -450,6 +548,9 @@ class NavigationScreen(
}
}
/**
* Loads a route to the specified place and sets it as the destination.
*/
fun navigateToPlace(place: Place) {
navigationType = NavigationType.VIEW
val location = location(place.longitude, place.latitude)
@@ -465,6 +566,9 @@ class NavigationScreen(
invalidate()
}
/**
* Stops navigation, resets state, and notifies listeners.
*/
fun stopNavigation() {
navigationType = NavigationType.VIEW
listener.stopNavigation()
@@ -473,6 +577,9 @@ class NavigationScreen(
invalidate()
}
/**
* Initiates recalculation for a new route to the destination.
*/
fun calculateNewRoute(destination: Place) {
stopNavigation()
navigationType = NavigationType.REROUTE
@@ -489,6 +596,9 @@ class NavigationScreen(
}
}
/**
* Re-requests a route for the specified destination.
*/
fun reRoute(destination: Place) {
val dest = location(destination.longitude, destination.latitude)
navigationViewModel.loadRoute(
@@ -499,6 +609,9 @@ class NavigationScreen(
)
}
/**
* Updates navigation state with the current location, checks for arrival, and traffic updates.
*/
fun updateTrip(location: Location) {
val current = LocalDateTime.now(ZoneOffset.UTC)
val duration = Duration.between(current, lastTrafficDate)
@@ -526,6 +639,24 @@ class NavigationScreen(
invalidate()
}
/**
* Updates the trip information and notifies the listener with a new Trip object.
* This includes destination name, address, travel estimate, and loading status.
*/
private fun updateTrip() {
val tripBuilder = Trip.Builder()
val destination = Destination.Builder()
.setName(routeModel.navState.destination.name ?: "")
.setAddress(routeModel.navState.destination.street ?: "")
.build()
tripBuilder.addDestination(destination, routeModel.travelEstimate(carContext, distanceMode))
tripBuilder.setLoading(false)
listener.updateTrip(tripBuilder.build())
}
/**
* Periodically requests speed camera information near the current location.
*/
private fun updateSpeedCamera(location: Location) {
if (lastCameraSearch++ % 100 == 0) {
navigationViewModel.getSpeedCameras(location, 5.0)
@@ -535,6 +666,9 @@ class NavigationScreen(
}
}
/**
* Updates distances to nearby speed cameras and checks for proximity alerts.
*/
private fun updateDistance(
location: Location,
) {
@@ -565,6 +699,9 @@ class NavigationScreen(
}
}
/**
* Checks for a specific permission and updates the view model upon grant.
*/
fun checkPermission(permission: String) {
if (carContext.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
val permissions: MutableList<String?> = ArrayList()
@@ -588,6 +725,9 @@ class NavigationScreen(
}
}
/**
* Defines the possible states for the navigation UI.
*/
enum class NavigationType {
VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL
}
}

View File

@@ -17,6 +17,7 @@ import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.RouteCarModel
@@ -25,6 +26,7 @@ import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.RECENT
import com.kouros.navigation.data.Place
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
class PlaceListScreen(

View File

@@ -0,0 +1,15 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
/**
* Observer for max speed updates.
*/
class MaxSpeedObserver(
private val callback: NavigationObserverCallback
) : Observer<Int> {
override fun onChanged(value: Int) {
callback.onMaxSpeedReceived(value)
}
}

View File

@@ -0,0 +1,34 @@
package com.kouros.navigation.car.screen.observers
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
/**
* Callback interface for observer events that need to be handled by NavigationScreen.
*/
interface NavigationObserverCallback {
/** Called when a route is received and navigation should start */
fun onRouteReceived(route: String)
/** Called when a recent place is selected but navigation hasn't started */
fun onRecentPlaceReceived(place: Place)
/** Check if currently navigating */
fun isNavigating(): Boolean
/** Called when traffic data is updated */
fun onTrafficReceived(traffic: Map<String, String>)
/** Called when a place search result is received */
fun onPlaceSearchResultReceived(place: Place)
/** Called when speed cameras are updated */
fun onSpeedCamerasReceived(cameras: List<Elements>)
/** Called when max speed is updated */
fun onMaxSpeedReceived(speed: Int)
/** Called to request UI invalidation/refresh */
fun invalidateScreen()
}

View File

@@ -0,0 +1,42 @@
package com.kouros.navigation.car.screen.observers
import com.kouros.navigation.model.NavigationViewModel
/**
* Manager class that handles all NavigationScreen observers.
* Centralizes observer creation, registration, and lifecycle management.
*/
class NavigationObserverManager(
private val viewModel: NavigationViewModel,
callback: NavigationObserverCallback
) {
val routeObserver = RouteObserver(callback)
val recentPlaceObserver = RecentPlaceObserver(callback)
val trafficObserver = TrafficObserver(callback)
val placeSearchObserver = PlaceSearchObserver(callback)
val speedCameraObserver = SpeedCameraObserver(callback)
val maxSpeedObserver = MaxSpeedObserver(callback)
/**
* Attaches all observers to the ViewModel.
* Call this from NavigationScreen's init block or lifecycle method.
*/
fun attachAllObservers(screen: androidx.car.app.Screen) {
viewModel.route.observe(screen, routeObserver)
viewModel.traffic.observe(screen, trafficObserver)
viewModel.recentPlace.observe(screen, recentPlaceObserver)
viewModel.placeLocation.observe(screen, placeSearchObserver)
viewModel.speedCameras.observe(screen, speedCameraObserver)
viewModel.maxSpeed.observe(screen, maxSpeedObserver)
}
/**
* Detaches all observers from the ViewModel.
* Call this when the screen is being destroyed.
*/
fun detachAllObservers() {
// LiveData observers are automatically removed when the lifecycle is destroyed,
// but we can manually remove them if needed for testing or cleanup
}
}

View File

@@ -0,0 +1,27 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
/**
* Observer for place search results. Converts SearchResult to Place and navigates to it.
*/
class PlaceSearchObserver(
private val callback: NavigationObserverCallback
) : Observer<SearchResult> {
override fun onChanged(value: SearchResult) {
val place = Place(
name = value.displayName,
street = value.address.road,
city = value.address.city,
latitude = value.lat.toDouble(),
longitude = value.lon.toDouble(),
category = Constants.CONTACTS,
postalCode = value.address.postcode
)
callback.onPlaceSearchResultReceived(place)
}
}

View File

@@ -0,0 +1,18 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
import com.kouros.navigation.data.Place
/**
* Observer for recent place updates. Updates the recent place when navigation is not active.
*/
class RecentPlaceObserver(
private val callback: NavigationObserverCallback
) : Observer<Place> {
override fun onChanged(value: Place) {
if (!callback.isNavigating()) {
callback.onRecentPlaceReceived(value)
}
}
}

View File

@@ -0,0 +1,17 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
/**
* Observer for route updates. Triggers navigation start when a non-empty route is received.
*/
class RouteObserver(
private val callback: NavigationObserverCallback
) : Observer<String> {
override fun onChanged(value: String) {
if (value.isNotEmpty()) {
callback.onRouteReceived(value)
}
}
}

View File

@@ -0,0 +1,16 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
import com.kouros.navigation.data.overpass.Elements
/**
* Observer for speed camera updates.
*/
class SpeedCameraObserver(
private val callback: NavigationObserverCallback
) : Observer<List<Elements>> {
override fun onChanged(value: List<Elements>) {
callback.onSpeedCamerasReceived(value)
}
}

View File

@@ -0,0 +1,16 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
/**
* Observer for traffic data updates.
*/
class TrafficObserver(
private val callback: NavigationObserverCallback
) : Observer<Map<String, String>> {
override fun onChanged(value: Map<String, String>) {
callback.onTrafficReceived(value)
callback.invalidateScreen()
}
}

View File

@@ -0,0 +1,153 @@
package com.kouros.navigation.car.screen.observers
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.nominatim.Address
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Tags
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.*
/**
* Unit tests for NavigationScreen observer classes using Mockito.
*/
class ObserversTest {
private lateinit var mockCallback: NavigationObserverCallback
@Before
fun setup() {
mockCallback = mock()
}
@Test
fun `RouteObserver triggers callback when route is not empty`() {
val observer = RouteObserver(mockCallback)
val testRoute = "test_route_data"
observer.onChanged(testRoute)
verify(mockCallback).onRouteReceived(testRoute)
}
@Test
fun `RouteObserver does not trigger callback when route is empty`() {
val observer = RouteObserver(mockCallback)
observer.onChanged("")
verify(mockCallback, never()).onRouteReceived(any())
}
@Test
fun `RecentPlaceObserver triggers callback when navigating is false`() {
val observer = RecentPlaceObserver(mockCallback)
val testPlace = createTestPlace()
whenever(mockCallback.isNavigating()).thenReturn(false)
observer.onChanged(testPlace)
verify(mockCallback).isNavigating()
verify(mockCallback).onRecentPlaceReceived(testPlace)
}
@Test
fun `RecentPlaceObserver does not trigger callback when navigating is true`() {
val observer = RecentPlaceObserver(mockCallback)
val testPlace = createTestPlace()
whenever(mockCallback.isNavigating()).thenReturn(true)
observer.onChanged(testPlace)
verify(mockCallback).isNavigating()
verify(mockCallback, never()).onRecentPlaceReceived(any())
}
@Test
fun `TrafficObserver triggers callback with traffic data and invalidates screen`() {
val observer = TrafficObserver(mockCallback)
val trafficData = mapOf("road1" to "congestion", "road2" to "clear")
observer.onChanged(trafficData)
verify(mockCallback).onTrafficReceived(trafficData)
verify(mockCallback).invalidateScreen()
}
@Test
fun `PlaceSearchObserver converts SearchResult to Place and triggers callback`() {
val observer = PlaceSearchObserver(mockCallback)
val searchResult = createTestSearchResult()
observer.onChanged(searchResult)
argumentCaptor<Place>().apply {
verify(mockCallback).onPlaceSearchResultReceived(capture())
assert(firstValue.name == "Test Place")
assert(firstValue.street == "Test Street")
assert(firstValue.city == "Test City")
assert(firstValue.latitude == 52.0)
assert(firstValue.longitude == 10.0)
assert(firstValue.category == Constants.CONTACTS)
assert(firstValue.postalCode == "12345")
}
}
@Test
fun `SpeedCameraObserver triggers callback with camera list`() {
val observer = SpeedCameraObserver(mockCallback)
val cameras = listOf(
createTestElements(10.0, 52.0, "50"),
createTestElements(10.1, 52.1, "30")
)
observer.onChanged(cameras)
verify(mockCallback).onSpeedCamerasReceived(cameras)
}
@Test
fun `MaxSpeedObserver triggers callback with speed value`() {
val observer = MaxSpeedObserver(mockCallback)
val testSpeed = 50
observer.onChanged(testSpeed)
verify(mockCallback).onMaxSpeedReceived(testSpeed)
}
// Helper methods
private fun createTestPlace(): Place {
return Place(
name = "Test Place",
street = "Test Street",
city = "Test City",
latitude = 52.0,
longitude = 10.0,
category = Constants.FAVORITES
)
}
private fun createTestSearchResult(): SearchResult {
return SearchResult(
displayName = "Test Place",
lat = "52.0",
lon = "10.0",
address = Address(
road = "Test Street",
city = "Test City",
postcode = "12345"
)
)
}
private fun createTestElements(lon: Double, lat: Double, maxSpeed: String): Elements {
return Elements(
lon = lon,
lat = lat,
tags = Tags(maxspeed = maxSpeed, direction = null)
)
}
}

View File

@@ -69,6 +69,4 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
}

View File

@@ -18,4 +18,5 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile

View File

@@ -20,4 +20,4 @@ data class NavigationState (
val carConnection: Int = 0,
val routingEngine: Int = 0,
val nextStep: Boolean = false,
)
)

View File

@@ -21,11 +21,11 @@ private const val DATASTORE_NAME = "navigation_settings"
class DataStoreManager(private val context: Context) {
companion object {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
}
// Keys
private object PreferencesKeys {
object PreferencesKeys {
val SHOW_3D = booleanPreferencesKey("Show3D")
@@ -45,7 +45,7 @@ class DataStoreManager(private val context: Context) {
val RECENT_PLACES = stringPreferencesKey("RecentPlaces")
val FAVORITES = stringPreferencesKey("Favorites")
val DISTANCE_MODE = intPreferencesKey("DistanceMode")
}
@@ -79,7 +79,7 @@ class DataStoreManager(private val context: Context) {
val routingEngineFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.ROUTING_ENGINE]
?: 0
?: 2
}
val lastRouteFlow: Flow<String> =
@@ -100,10 +100,11 @@ class DataStoreManager(private val context: Context) {
?: ""
}
val favoritesFlow: Flow<String> =
val distanceModeFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.FAVORITES]
?: ""
preferences[PreferencesKeys.DISTANCE_MODE]
?: 0
}
// Save values
@@ -161,9 +162,10 @@ class DataStoreManager(private val context: Context) {
}
}
suspend fun setFavorites(apiKey: String) {
suspend fun setDistanceMode(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.FAVORITES] = apiKey
prefs[PreferencesKeys.DISTANCE_MODE] = mode
}
}
}

View File

@@ -2,7 +2,11 @@ package com.kouros.navigation.data.route
data class Summary(
// sec
var duration : Double = 0.0,
// km
var distance : Double = 0.0,
var duration: Double = 0.0,
// m
var distance: Double = 0.0,
// sec
var trafficDelay: Double = 0.0,
// m
var trafficLength: Double = 0.0,
)

View File

@@ -25,7 +25,9 @@ class TomTomRoute {
var points = listOf<List<Double>>()
val summary = Summary(
route.summary.travelTimeInSeconds.toDouble(),
route.summary.lengthInMeters.toDouble()
route.summary.lengthInMeters.toDouble(),
route.summary.trafficDelayInSeconds.toDouble(),
route.summary.trafficLengthInMeters.toDouble()
)
route.legs.forEach { leg ->
points = decodePolyline(leg.encodedPolyline, leg.encodedPolylinePrecision)

View File

@@ -3,6 +3,7 @@ package com.kouros.navigation.model
//import com.kouros.navigation.data.Preferences.boxStore
import android.content.Context
import android.location.Location
import android.util.Log
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled

View File

@@ -69,7 +69,7 @@ class RouteCalculator(var routeModel: RouteModel) {
distance
}
}
return (leftDistance / 10.0).roundToInt() * 10.0
return leftDistance.toDouble()
}
/** Returns the left distance in m. */

View File

@@ -1,7 +1,10 @@
package com.kouros.navigation.model
import androidx.datastore.preferences.core.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kouros.navigation.data.datastore.DataStoreManager.Companion.dataStore
import com.kouros.navigation.data.datastore.DataStoreManager.PreferencesKeys
import com.kouros.navigation.repository.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -63,6 +66,12 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
""
)
val distanceMode = repository.distanceModeFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
0
)
fun onShow3DChanged(enabled: Boolean) {
viewModelScope.launch { repository.setShow3D(enabled) }
}
@@ -94,4 +103,9 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
fun onTomTomApiKeyChanged(key: String) {
viewModelScope.launch { repository.setTomTomApiKey(key) }
}
fun onDistanceModeChanged(mode: Int) {
viewModelScope.launch { repository.setDistanceMode(mode) }
}
}

View File

@@ -33,8 +33,8 @@ class SettingsRepository(
val recentPlacesFlow: Flow<String> =
dataStoreManager.recentPlacesFlow
val favoritesFlow: Flow<String> =
dataStoreManager.favoritesFlow
val distanceModeFlow: Flow<Int> =
dataStoreManager.distanceModeFlow
suspend fun setShow3D(enabled: Boolean) {
@@ -73,7 +73,9 @@ class SettingsRepository(
dataStoreManager.setRecentPlaces(places)
}
suspend fun setFavorites(favorites: String) {
dataStoreManager.setFavorites(favorites)
suspend fun setDistanceMode(mode: Int) {
dataStoreManager.setDistanceMode(mode)
}
}

View File

@@ -3,6 +3,7 @@ package com.kouros.navigation.utils
import android.content.Context
import android.location.Location
import android.location.LocationManager
import androidx.car.app.model.Distance
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
@@ -16,6 +17,7 @@ import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import kotlin.math.absoluteValue
import kotlin.math.pow
import kotlin.math.roundToInt
@@ -109,4 +111,34 @@ fun duration(preview: Boolean, bearing: Double, lastBearing: Double): Duration {
1.seconds
}
return cameraDuration
}
}
fun isMetricSystem(): Boolean {
val country = Locale.getDefault().country
// Return true for metric, false for imperial
return !setOf("US", "UK", "LR", "MM").contains(country)
}
fun formattedDistance(distanceMode : Int, distance: Double): Pair<Double, Int> {
var currentDistance = distance
var displayUnit: Int
if (distanceMode == 1 || distanceMode == 0 && isMetricSystem()) {
displayUnit = if (currentDistance > 1000.0) {
currentDistance /= 1000.0
Distance.UNIT_KILOMETERS
} else {
currentDistance = (currentDistance / 10.0).roundToInt() * 10.0
Distance.UNIT_METERS
}
} else {
currentDistance *= 0.621371
displayUnit = if (currentDistance > 1000.0) {
currentDistance /= 1000.0
Distance.UNIT_MILES
} else {
currentDistance = (currentDistance / 10.0).roundToInt() * 10.0
Distance.UNIT_FEET
}
}
return Pair(currentDistance, displayUnit)
}

View File

@@ -2,6 +2,7 @@ package com.kouros.navigation.utils
import android.content.Context
import androidx.car.app.CarContext
import androidx.car.app.model.Distance
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
@@ -13,6 +14,9 @@ import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.roundToInt
@Composable

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -36,8 +36,8 @@
<string name="use_telephon_settings">Verwende Telefon Einstellungen</string>
<string name="threed_building">3D Gebäude</string>
<string name="drive_now">Losfahren</string>
<string name="avoid_tolls_row_title" msgid="5194057244144831024">"Mautstraßen meiden"</string>
<string name="avoid_highways_row_title" msgid="4711913426200490304">"Autobahnen meiden"</string>
<string name="avoid_tolls_row_title" msgid="5194057244144831024">"Mautstraßen vermeiden"</string>
<string name="avoid_highways_row_title" msgid="4711913426200490304">"Autobahnen vermeiden"</string>
<string name="recent_destinations">Letzte Ziele</string>
<string name="contacts">Kontakte</string>
<string name="route_preview">Route Vorschau</string>
@@ -54,5 +54,8 @@
<string name="use_car_settings">Verwende Auto Einstellungen</string>
<string name="exit_number">Ausfahrt nummer</string>
<string name="navigation_icon_description">Navigations Icon</string>
<string name="distance_units">Entfernungseinheiten</string>
<string name="automaticaly">Automatisch</string>
<string name="kilometer">Kilometer</string>
<string name="miles">Meilen</string>
</resources>

View File

@@ -18,7 +18,7 @@
<string name="avoid_highways_row_title">Avoid highways</string>
<string name="avoid_tolls_row_title">Avoid tolls rows</string>
<string name="no_places">No places</string>
<string name="recent_destinations">Recent destination</string>
<string name="recent_destinations">Recent destinations</string>
<string name="contacts">Contacts</string>
<string name="favorites">Favorites</string>
<string name="recent_Item_deleted">Recent item deleted</string>
@@ -40,4 +40,8 @@
<string name="use_car_settings">Use car settings</string>
<string name="exit_number">Exit number</string>
<string name="navigation_icon_description">Navigation icon</string>
<string name="distance_units">Distance units</string>
<string name="automaticaly">Automaticaly</string>
<string name="kilometer">Kilometer</string>
<string name="miles">Miles</string>
</resources>

View File

@@ -35,33 +35,33 @@ class IconMapperTest {
@Test
fun `addLanes returns correct lane direction`() {
val stepDataNormalLeft = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
val stepDataNormalLeft = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_NORMAL_LEFT, iconMapper.addLanes("left_straight", stepDataNormalLeft))
assertEquals(LaneDirection.SHAPE_NORMAL_LEFT, iconMapper.addLanes("left", stepDataNormalLeft))
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("left_slight", stepDataNormalLeft))
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("slight_left", stepDataNormalLeft))
val stepDataStraight = StepData("", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
val stepDataStraight = StepData("", "", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("left_straight", stepDataStraight))
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataStraight))
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("right_straight", stepDataStraight))
val stepDataKeepLeft = StepData("", 0.0, Maneuver.TYPE_KEEP_LEFT, 0, 0L, 0.0)
val stepDataKeepLeft = StepData("", "", 0.0, Maneuver.TYPE_KEEP_LEFT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataKeepLeft))
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("left_slight", stepDataKeepLeft))
val stepDataKeepRight = StepData("", 0.0, Maneuver.TYPE_KEEP_RIGHT, 0, 0L, 0.0)
val stepDataKeepRight = StepData("", "", 0.0, Maneuver.TYPE_KEEP_RIGHT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataKeepRight))
val stepDataNormalRight = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
val stepDataNormalRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right", stepDataNormalRight))
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right_straight", stepDataNormalRight))
val stepDataSlightRight = StepData("", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
val stepDataSlightRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right_slight", stepDataSlightRight))
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("slight_right", stepDataSlightRight))
val stepDataUnknown = StepData("", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
val stepDataUnknown = StepData("", "", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("left_straight", stepDataUnknown))
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("left", stepDataUnknown))
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("straight", stepDataUnknown))
@@ -74,24 +74,24 @@ class IconMapperTest {
@Test
fun `laneToResource returns correct resource string`() {
val stepDataNormalLeft = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
val stepDataNormalLeft = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
assertEquals("left_o_straight_x", iconMapper.laneToResource(listOf("left", "straight"), stepDataNormalLeft))
assertEquals("left_o", iconMapper.laneToResource(listOf("left"), stepDataNormalLeft))
assertEquals("slight_left_o", iconMapper.laneToResource(listOf("slight_left"), stepDataNormalLeft))
val stepDataStraight = StepData("", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
val stepDataStraight = StepData("", "", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
assertEquals("left_x_straight_o", iconMapper.laneToResource(listOf("left", "straight"), stepDataStraight))
assertEquals("straight_o", iconMapper.laneToResource(listOf("straight"), stepDataStraight))
assertEquals("right_x_straight_o", iconMapper.laneToResource(listOf("right_straight"), stepDataStraight))
val stepDataNormalRight = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
val stepDataNormalRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
assertEquals("right_x_straight_x", iconMapper.laneToResource(listOf("right_straight"), stepDataNormalRight))
assertEquals("right_o", iconMapper.laneToResource(listOf("right"), stepDataNormalRight))
val stepDataSlightRight = StepData("", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
val stepDataSlightRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
assertEquals("right_o_straight_o", iconMapper.laneToResource(listOf("right_straight"), stepDataSlightRight))
val stepDataUnknown = StepData("", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
val stepDataUnknown = StepData("", "", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
assertEquals("left_x_straight_x", iconMapper.laneToResource(listOf("left", "straight"), stepDataUnknown))
assertEquals("", iconMapper.laneToResource(listOf("invalid"), stepDataUnknown))
}