Distance settings
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user