This commit is contained in:
Dimitris
2026-03-21 12:24:53 +01:00
parent ada878b23c
commit d1968cfa68
45 changed files with 1121 additions and 935 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId = "com.kouros.navigation"
minSdk = 33
targetSdk = 36
versionCode = 71
versionName = "0.2.0.71"
versionCode = 73
versionName = "0.2.0.73"
base.archivesName = "navi-$versionName"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -294,7 +294,7 @@ class MainActivity : ComponentActivity() {
fun updateLocation(location: Location?) {
if (location != null && lastLocation.latitude != location.position.latitude && lastLocation.longitude != location.position.longitude) {
val currentLocation = location(location.position.longitude, location.position.latitude)
if (location.bearing != null) {
if (location.bearing != null && location.bearingAccuracy!! <= 20.0) {
currentLocation.bearing = location.bearing!!.toFloat()
}
if (routeModel.isNavigating()) {

View File

@@ -1,22 +1,17 @@
package com.kouros.navigation.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -24,7 +19,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -36,7 +30,6 @@ import com.kouros.navigation.ui.components.SectionTitle
import com.kouros.navigation.ui.components.SettingSwitch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisplayScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
@@ -45,6 +38,7 @@ fun DisplayScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
val show3D by viewModel.show3D.collectAsState()
val showTraffic by viewModel.traffic.collectAsState()
val distanceMode by viewModel.distanceMode.collectAsState()
val driveSuggestion by viewModel.tripSuggestion.collectAsState()
Scaffold(
topBar = {
@@ -90,6 +84,12 @@ fun DisplayScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
checked = showTraffic,
onCheckedChange = viewModel::onTraffic
)
SettingSwitch(
title = stringResource(R.string.trip_suggestion),
checked = driveSuggestion,
onCheckedChange = viewModel::onTripSuggestion
)
}
}

View File

@@ -1,3 +1,4 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
@@ -31,6 +32,9 @@ android {
}
}
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.car.app)
@@ -48,10 +52,19 @@ dependencies {
implementation(libs.play.services.location)
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.monitor)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
}
testImplementation(libs.androidx.car.app.testing)
testImplementation(libs.robolectric)
testImplementation(libs.google.truth)
testImplementation(libs.androidx.test.core)
mockitoAgent(libs.mockito.core) { isTransitive = false }
}

View File

@@ -38,8 +38,10 @@ class RouteModelTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val repository = getSettingsRepository(appContext)
runBlocking { repository.setRoutingEngine(RouteEngine.TOMTOM.ordinal) }
val routeJson = appContext.resources.openRawResource(R.raw.tomtom_routing)
val routeJsonString = routeJson.bufferedReader().use { it.readText() }
val routeJsonString = TomTomRepository().fetchUrl(
"https://kouros-online.de/tomtom_routing.json",
false
)
assertNotEquals("", routeJsonString)
routeModel.navState = routeModel.navState.copy(routingEngine = RouteEngine.TOMTOM.ordinal)
routeModel.startNavigation(routeJsonString)
@@ -49,7 +51,7 @@ class RouteModelTest {
fun checkRoute() {
assertEquals(true, routeModel.isNavigating())
assertEquals(routeModel.curRoute.summary.distance, 11116.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1483.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1581.0, 10.0)
}
@Test
@@ -60,7 +62,7 @@ class RouteModelTest {
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.instruction, "Silcherstraße")
assertEquals(stepData.leftStepDistance, 25.0, 1.0)
assertEquals(stepData.leftStepDistance, 20.0, 5.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.instruction, "Schmalkaldener Straße")
@@ -141,7 +143,7 @@ class RouteModelTest {
if (index in 61..61) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 2)
assertEquals(stepData.lane.size, 3)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.first().indications.first(), "STRAIGHT")
}
@@ -203,6 +205,6 @@ class RouteModelTest {
val location: Location = location(11.578911, 48.185565)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 34.0, 1.0)
assertEquals(step.leftStepDistance, 26.0, 1.0)
}
}

View File

@@ -24,9 +24,11 @@ import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.navigation.Simulation
import com.kouros.navigation.car.screen.NavigationListener
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.car.screen.checkPermission
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.INSTRUCTION_DISTANCE
@@ -41,9 +43,12 @@ import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
@@ -52,7 +57,7 @@ import kotlinx.coroutines.launch
* car hardware sensors, routing engine selection, and screen navigation.
* Implements NavigationScreen.Listener for handling navigation events.
*/
class NavigationSession : Session(), NavigationScreen.Listener {
class NavigationSession : Session(), NavigationListener {
// Flag to enable/disable contact access feature
val useContacts = false
@@ -80,6 +85,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
val simulation = Simulation()
var navigationManagerStarted = false
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
@@ -148,11 +155,11 @@ class NavigationSession : Session(), NavigationScreen.Listener {
when (connectionState) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
CarConnection.CONNECTION_TYPE_NATIVE -> {
navigationScreen.checkPermission(AUTOMOTIVE_CAR_SPEED_PERMISSION)
navigationViewModel.permissionGranted.value = checkPermission(carContext,AUTOMOTIVE_CAR_SPEED_PERMISSION)
}
CarConnection.CONNECTION_TYPE_PROJECTION -> {
navigationScreen.checkPermission(GMS_CAR_SPEED_PERMISSION)
navigationViewModel.permissionGranted.value = checkPermission(carContext, GMS_CAR_SPEED_PERMISSION)
}
}
}
@@ -210,6 +217,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
// Called when the app should simulate navigation (e.g., for testing)
deviceLocationManager.stopLocationUpdates()
autoDriveEnabled = true
surfaceRenderer.viewStyle = ViewStyle.VIEW
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
) { location ->
@@ -241,10 +249,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
shouldUseCarLocationFlow = carSensorManager.shouldUseCarLocation(),
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
navigationViewModel.loadRecentPlace(
navigationViewModel.loadRecentPlaces(
carContext,
location,
surfaceRenderer.carOrientation,
carContext
)
}
)
@@ -325,7 +333,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
private fun handleNavigateIntent(screenManager: ScreenManager) {
screenManager.popToRoot()
screenManager.pushForResult(
SearchScreen(carContext, surfaceRenderer, navigationViewModel)
SearchScreen(carContext, surfaceRenderer, navigationViewModel, emptyList())
) { result ->
// Handle search result if needed
}
@@ -359,6 +367,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
if (routeModel.isNavigating()) {
handleNavigationLocation(location)
} else {
navigationScreen.checkTraffic(LocalDateTime.now(ZoneOffset.UTC), location)
surfaceRenderer.updateLocation(location)
}
}
@@ -417,7 +426,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Called when user starts navigation
*/
override fun startNavigation() {
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationManager.navigationStarted()
navigationManagerStarted = true
if (autoDriveEnabled) {
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
@@ -428,7 +439,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
}
override fun updateTrip(trip: Trip) {
navigationManager.updateTrip(trip)
if (navigationManagerStarted) {
navigationManager.updateTrip(trip)
}
}
/**

View File

@@ -392,6 +392,7 @@ class SurfaceRenderer(
* Sets route data for active navigation and switches to VIEW mode.
*/
fun setRouteData() {
println("SetRouteData")
routeData.value = routeModel.curRoute.routeGeoJson
viewStyle = ViewStyle.VIEW
}
@@ -457,6 +458,11 @@ class SurfaceRenderer(
}
fun updateTrafficData(routeModel: RouteCarModel) {
}
/**
* Displays a specific location (e.g., amenity/POI) on the map.
*/

View File

@@ -31,30 +31,6 @@ import java.util.Locale
class NavigationUtils(private var carContext: CarContext) {
private fun createToastAction(
@StringRes titleRes: Int, @StringRes toastStringRes: Int,
flags: Int
): Action {
return Action.Builder()
.setOnClickListener { showToast(toastStringRes) }
.setTitle(createCarText(titleRes))
.setFlags(flags)
.build()
}
fun showToast(@StringRes toastStringRes: Int) {
CarToast.makeText(
carContext,
carContext.getString(toastStringRes),
CarToast.LENGTH_SHORT
)
.show()
}
fun createCarText(@StringRes stringRes: Int): CarText {
return CarText.create(carContext.getString(stringRes))
}
fun createCarIcon(@DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()

View File

@@ -1,9 +1,8 @@
package com.kouros.navigation.car.navigation
import android.speech.tts.TextToSpeech
import android.text.SpannableString
import android.util.Log
import androidx.annotation.DrawableRes
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.annotation.StringRes
import androidx.car.app.AppManager
import androidx.car.app.CarContext
@@ -16,6 +15,8 @@ import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.DateTimeWithZone
import androidx.car.app.model.Distance
import androidx.car.app.model.DurationSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.Maneuver
@@ -25,10 +26,11 @@ import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.car.screen.createCarIcon
import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.formattedDistance
import java.util.Locale
import java.time.Duration
import java.util.TimeZone
import java.util.concurrent.TimeUnit
@@ -115,8 +117,8 @@ class RouteCarModel : RouteModel() {
.setRemainingTimeColor(CarColor.GREEN)
.setRemainingDistanceColor(CarColor.BLUE)
if (traffic > 0) {
travelBuilder.setTripText(CarText.create("$traffic min"))
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
travelBuilder.setTripText(createDelay(traffic))
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.traffic_jam_48px))
}
if (navState.travelMessage.isNotEmpty()) {
@@ -125,7 +127,22 @@ class RouteCarModel : RouteModel() {
}
return travelBuilder.build()
}
private fun createDelay(delay: Int): CarText {
val delayBuilder = SpannableStringBuilder()
delayBuilder.append(
" ",
DurationSpan.create(Duration.ofMinutes(delay.toLong())),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
delayBuilder.setSpan(
ForegroundCarColorSpan.create(CarColor.RED),
0,
1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
return CarText.Builder(delayBuilder)
.build()
}
fun addLanes(carContext: CarContext, step: Step.Builder, stepData: StepData) : Boolean {
var laneImageAdded = false
stepData.lane.forEach {
@@ -167,9 +184,6 @@ class RouteCarModel : RouteModel() {
return CarText.create(carContext.getString(stringRes))
}
fun createCarIcon(carContext: CarContext, @DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()
}
fun createCarIcon(iconCompat: IconCompat): CarIcon {
return CarIcon.Builder(iconCompat).build()

View File

@@ -19,10 +19,6 @@ class Simulation {
lifecycleScope: LifecycleCoroutineScope,
updateLocation: (Location) -> Unit
) {
// A92
//updateLocation(location(11.709508, 48.338923 ))
//updateLocation(homeVogelhart)
if (routeModel.navState.route.isRouteValid()) {
val points = routeModel.curRoute.waypoints
if (points.isEmpty()) return

View File

@@ -30,9 +30,8 @@ class CategoriesScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
private val navigationViewModel: NavigationViewModel,
) : Screen(carContext), CategoryObserverCallback {
) : Screen(carContext) {
private val categoryObserver = CategoryObserver(this)
private var category = ""
var categories: List<Category> = listOf(
@@ -41,17 +40,9 @@ class CategoriesScreen(
Category(id = CHARGING_STATION, name = carContext.getString(R.string.charging_station))
)
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
navigationViewModel.elements.value = emptyList()
invalidate()
}
}
init {
navigationViewModel.elements.value = emptyList()
navigationViewModel.elements.observe(this, categoryObserver)
carContext.onBackPressedDispatcher.addCallback(this, backPressedCallback)
}
override fun onGetTemplate(): Template {
@@ -61,10 +52,23 @@ class CategoriesScreen(
itemListBuilder.addItem(
Row.Builder()
.setTitle(it.name)
.setImage(carIcon(carContext,it.id, -1))
.setImage(carIcon(carContext, it.id, -1))
.setOnClickListener {
category = it.id
navigationViewModel.getAmenities(it.id, surfaceRenderer.lastLocation)
screenManager
.pushForResult(
CategoryScreen(
carContext,
surfaceRenderer,
category,
navigationViewModel,
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
.setBrowsable(true)
.build()
@@ -83,36 +87,6 @@ class CategoriesScreen(
.setSingleList(itemListBuilder.build())
.build()
}
override fun onCategoryElementsReady(
elements: List<Elements>,
centerLat: Double,
centerLon: Double,
coordinates: List<List<Double>>
) {
val loc = location(centerLon, centerLat)
val route = createPointCollection(coordinates, category)
surfaceRenderer.setCategories(loc, route)
screenManager
.pushForResult(
CategoryScreen(
carContext,
surfaceRenderer,
category,
navigationViewModel,
elements
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
override fun invalidateScreen() {
}
}
fun carIcon(context: CarContext, category: String, index: Int): CarIcon {
@@ -125,7 +99,12 @@ fun carIcon(context: CarContext, category: String, index: Int): CarIcon {
}
return CarIcon.Builder(IconCompat.createWithResource(context, resId)).build()
} else {
return CarIcon.Builder(NavigationUtils(context).createNumberIcon(category, index.toString())).build()
return CarIcon.Builder(
NavigationUtils(context).createNumberIcon(
category,
index.toString()
)
).build()
}
}

View File

@@ -5,6 +5,8 @@ import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarText
import androidx.car.app.model.Header
@@ -15,6 +17,8 @@ import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.versioning.CarAppApiLevels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.NavigationUtils
@@ -37,11 +41,25 @@ class CategoryScreen(
private val surfaceRenderer: SurfaceRenderer,
private val category: String,
private val navigationViewModel: NavigationViewModel,
private var elements: List<Elements>,
) : Screen(carContext) {
) : Screen(carContext), CategoryObserverCallback {
val maxListItems: Int = 30
var elements: List<Elements> = emptyList()
private val categoryObserver = CategoryObserver(this)
private var loading = true
init {
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
navigationViewModel.elements.value = emptyList()
}
})
navigationViewModel.elements.observe(this, categoryObserver)
navigationViewModel.getAmenities(category, surfaceRenderer.lastLocation)
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
@@ -72,17 +90,22 @@ class CategoryScreen(
.setTitle(getTitle(carContext, category))
.build()
val builder = MapWithContentTemplate.Builder()
.setContentTemplate(
ListTemplate.Builder()
.setHeader(header)
.setSingleList(listBuilder.build())
.build()
)
.setMapController(
MapController.Builder().setMapActionStrip(
getMapActionStrip()
).build()
)
val content = ListTemplate.Builder()
.setHeader(header)
if (loading) {
content.setLoading(true)
} else {
content.setSingleList(listBuilder.build())
}
.build()
builder.setContentTemplate(content.build())
return builder.build()
}
@@ -122,28 +145,24 @@ class CategoryScreen(
} else {
row.addText(carText("${it.tags.openingHours}"))
}
val navigationUtils = NavigationUtils(carContext)
row.addAction(
Action.Builder()
.setOnClickListener {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
location(it.lon, it.lat),
surfaceRenderer.carOrientation
createAction(carContext, R.drawable.navigation_48px, FLAG_DEFAULT, {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
location(it.lon, it.lat),
surfaceRenderer.carOrientation
)
setResult(
Place(
name = name,
category = CHARGING_STATION,
latitude = it.lat,
longitude = it.lon
)
setResult(
Place(
name = name,
category = CHARGING_STATION,
latitude = it.lat,
longitude = it.lon
)
)
finish()
}
.setIcon(navigationUtils.createCarIcon(R.drawable.navigation_48px))
.build())
)
finish()
}))
return row.build()
}
@@ -179,10 +198,25 @@ class CategoryScreen(
@DrawableRes iconRes: Int,
scale: Int
): Action {
val navigationUtils = NavigationUtils(carContext)
return Action.Builder()
.setOnClickListener { surfaceRenderer.handleScale(scale) }
.setIcon(navigationUtils.createCarIcon(iconRes))
.build()
return createAction(carContext, iconRes, FLAG_IS_PERSISTENT, {
surfaceRenderer.handleScale(scale)
})
}
override fun onCategoryElementsReady(
elements: List<Elements>,
centerLat: Double,
centerLon: Double,
coordinates: List<List<Double>>
) {
val loc = location(centerLon, centerLat)
val route = createPointCollection(coordinates, category)
surfaceRenderer.setCategories(loc, route)
this.elements = elements
loading = false
}
override fun invalidateScreen() {
invalidate()
}
}

View File

@@ -0,0 +1,17 @@
package com.kouros.navigation.car.screen
import androidx.car.app.navigation.model.Trip
/** A listener for navigation start and stop signals. */
interface NavigationListener {
/** Stops navigation. */
fun stopNavigation()
/** Starts navigation. */
fun startNavigation()
/** Updates trip information. */
fun updateTrip(trip: Trip)
}

View File

@@ -1,30 +1,33 @@
package com.kouros.navigation.car.screen
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.CountDownTimer
import android.os.Handler
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.model.Header
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
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.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
@@ -35,11 +38,14 @@ 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.car.screen.settings.SettingsScreen
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Constants.ROUTE_UPDATE
import com.kouros.navigation.data.Constants.TRAFFIC_UPDATE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.getSettingsRepository
@@ -47,7 +53,6 @@ import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
@@ -57,154 +62,98 @@ import kotlin.math.absoluteValue
* Main screen for car navigation.
* Handles different navigation states and provides corresponding templates.
*/
class NavigationScreen(
open class NavigationScreen(
carContext: CarContext,
private var surfaceRenderer: SurfaceRenderer,
private var routeModel: RouteCarModel,
private var listener: Listener,
private var listener: NavigationListener,
private val navigationViewModel: NavigationViewModel
) : Screen(carContext), NavigationObserverCallback {
/** A listener for navigation start and stop signals. */
interface Listener {
/** Stops navigation. */
fun stopNavigation()
val backGroundColor = CarColor.GREEN
/** 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 recentPlaces = emptyList<Place>()
var recentPlace: Place = Place()
var navigationType = NavigationType.VIEW
var lastTrafficDate: LocalDateTime? = LocalDateTime.of(1960, 6, 21, 0, 0)
var lastTrafficDate: LocalDateTime = LocalDateTime.MIN
var lastRouteDate: LocalDateTime = LocalDateTime.MIN
var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
val observerManager = NavigationObserverManager(navigationViewModel, this)
val repository = getSettingsRepository(carContext)
val settingsViewModel = getSettingsViewModel(carContext)
var distanceMode = 0
private var distanceMode = 0
init {
observerManager.attachAllObservers(this)
lifecycleScope.launch {
settingsViewModel.routingEngine.first()
settingsViewModel.recentPlaces.first()
}
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
private var tripSuggestion = false
/**
* Handles the received route string.
* Starts navigation and invalidates the screen.
*/
override fun onRouteReceived(route: String) {
if (route.isNotEmpty()) {
prepareRoute(route)
private var tripSuggestionCalled = false
private var routingEngine = 0
private var showTraffic = false;
private var arrivalTimer: CountDownTimer? = null
private var reRouteTimer: CountDownTimer? = null
val observerRecentPlaces = Observer<List<Place>> { newPlaces ->
recentPlaces = newPlaces
if (newPlaces.isNotEmpty() && !tripSuggestionCalled) {
tripSuggestionCalled = true
navigationType = NavigationType.RECENT
invalidate()
}
}
/**
* Prepare route and start navigation
*/
private fun prepareRoute(route: String) {
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route)
if (routeModel.hasLegs()) {
settingsViewModel.onLastRouteChanged(route)
init {
observerManager.attachAllObservers(this)
lifecycleScope.launch {
settingsViewModel.tripSuggestion.first()
settingsViewModel.routingEngine.first()
}
surfaceRenderer.setRouteData()
listener.startNavigation()
}
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
/**
* 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>>()
cameras.forEach {
coordinates.add(listOf(it.lon, it.lat))
}
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
surfaceRenderer.speedCamerasData.value = speedData
}
/**
* Handles received maximum speed data and updates the surface renderer.
*/
override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed
}
/**
* Handles the received previe route string.
* Starts navigation and invalidates the screen.
*/
override fun onPreviewRouteReceived(route: String) {
if (navigationType == NavigationType.NAVIGATION && route.isNotEmpty()) {
startPreviewScreen(route)
}
}
/**
* Invalidates the screen.
*/
override fun invalidateScreen() {
invalidate()
repository.trafficFlow.asLiveData().observe(this, Observer {
showTraffic = it
})
repository.tripSuggestionFlow.asLiveData().observe(this, Observer {
navigationViewModel.recentPlaces.observe(this, observerRecentPlaces)
tripSuggestion = it
})
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
arrivalTimer?.cancel()
reRouteTimer?.cancel()
}
})
}
/**
* Returns the appropriate template based on the current navigation state.
*/
override fun onGetTemplate(): Template {
val actionStripBuilder = createActionStripBuilder()
val actionStripBuilder = createActionStripBuilder({
createAction(
carContext,
R.drawable.search_48px,
FLAG_IS_PERSISTENT,
onClickAction = { startSearchScreen()}
)
}, { settingsAction() })
return when (navigationType) {
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
NavigationType.RECENT -> navigationRecentPlaceTemplate()
NavigationType.RECENT -> navigationRecentPlacesTemplate()
NavigationType.REROUTE -> navigationRerouteTemplate(actionStripBuilder)
NavigationType.ARRIVAL -> navigationEndTemplate(actionStripBuilder)
else -> navigationViewTemplate(actionStripBuilder)
@@ -214,9 +163,9 @@ class NavigationScreen(
/**
* Creates and returns a NavigationTemplate for the active navigation state.
*/
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): Template {
actionStripBuilder.addAction(
stopAction()
createAction(carContext, R.drawable.ic_close_white_24dp, FLAG_IS_PERSISTENT,{ stopNavigation() })
)
updateTrip()
return NavigationTemplate.Builder()
@@ -225,7 +174,20 @@ class NavigationScreen(
)
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext, distanceMode))
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setMapActionStrip(
mapActionStrip(
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_zoom_out_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
}
)
})
)
.setBackgroundColor(backGroundColor)
.build()
}
@@ -235,38 +197,41 @@ class NavigationScreen(
*/
private fun navigationViewTemplate(actionStripBuilder: ActionStrip.Builder): Template {
return NavigationTemplate.Builder()
.setBackgroundColor(CarColor.SECONDARY)
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setMapActionStrip(
mapActionStrip(
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_zoom_out_24,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
.build()
}
/**
* Creates and returns a template for the arrival or end state of navigation.
* Creates and returns a template for the arrival.
*/
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
if (routeModel.navState.arrived) {
val timer = object : CountDownTimer(8000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
routeModel.navState = routeModel.navState.copy(arrived = false)
navigationType = NavigationType.VIEW
invalidate()
}
arrivalTimer?.cancel()
arrivalTimer = object : CountDownTimer(8000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
routeModel.navState = routeModel.navState.copy(arrived = false)
navigationType = NavigationType.VIEW
invalidate()
}
timer.start()
return navigationArrivedTemplate(actionStripBuilder)
} else {
return NavigationTemplate.Builder()
.setBackgroundColor(CarColor.SECONDARY)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.build()
}
arrivalTimer?.start()
return navigationArrivedTemplate(actionStripBuilder)
}
/**
* Creates and returns a NavigationTemplate specifically for when the destination is reached.
*/
@@ -292,36 +257,82 @@ class NavigationScreen(
)
.build()
)
.setBackgroundColor(CarColor.SECONDARY)
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setMapActionStrip(
mapActionStrip(
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_zoom_out_24,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
.build()
}
/**
* Creates and returns a template showing recent places or destinations.
*/
fun navigationRecentPlaceTemplate(): Template {
val messageTemplate = MessageTemplate.Builder(
recentPlace.name + "\n"
+ recentPlace.city
)
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.drive_now))
.build()
fun navigationRecentPlacesTemplate(): Template {
if (!tripSuggestion || recentPlaces.isEmpty()) {
navigationType = NavigationType.VIEW
return navigationViewTemplate(
createActionStripBuilder(
{
createAction(
carContext,
R.drawable.search_48px,
FLAG_IS_PERSISTENT,
{ startSearchScreen() })
},
{ settingsAction() })
)
.addAction(navigateAction())
.addAction(closeAction())
.build()
}
val listBuilder = ItemList.Builder()
recentPlaces.filter { it.category == Constants.RECENT }.forEach {
val row = Row.Builder()
.setTitle(it.name!!)
.addAction(
createNavigateAction(it)
)
.setOnClickListener {
navigateToPlace(it)
}
listBuilder.addItem(
row.build()
)
}
val contentTemplate =
ListTemplate.Builder()
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.drive_now))
.addEndHeaderAction(closeAction())
.build()
)
.setSingleList(listBuilder.build())
.build()
val builder = MapWithContentTemplate.Builder()
.setContentTemplate(messageTemplate)
.setContentTemplate(contentTemplate)
.setActionStrip(
mapActionStripBuilder()
.addAction(settingsAction())
.addAction(searchAction())
.build()
mapActionStrip(
ViewStyle.VIEW,
{ settingsAction() },
{ createAction(carContext, R.drawable.search_48px, FLAG_IS_PERSISTENT,{ startSearchScreen() }) },
{
createAction(
carContext = carContext, R.drawable.ic_zoom_out_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
return builder.build()
}
@@ -355,208 +366,98 @@ 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(
searchAction()
)
actionStripBuilder.addAction(
settingsAction()
)
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())
.addAction(zoomMinus())
if (surfaceRenderer.viewStyle == ViewStyle.PAN_VIEW) {
actionStripBuilder
.addAction(
panAction()
)
}
return actionStripBuilder
}
/**
* Creates a stop navigation action.
*/
private fun stopAction(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_close_white_24dp
)
)
.build()
)
.setOnClickListener {
stopNavigation()
}
.build()
}
/**
* Creates an action to start navigation to a specific place.
*/
private fun navigateAction(): Action {
navigationType = NavigationType.NAVIGATION
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.navigation_48px
)
)
.build()
)
.setOnClickListener {
val navigateTo = location(recentPlace.longitude, recentPlace.latitude)
navigationViewModel.loadPreviewRoute(
carContext,
surfaceRenderer.lastLocation,
navigateTo,
surfaceRenderer.carOrientation
)
// val navigateTo = location(recentPlace.longitude, recentPlace.latitude)
// navigationViewModel.loadRoute(
// carContext,
// surfaceRenderer.lastLocation,
// navigateTo,
// surfaceRenderer.carOrientation
// )
// routeModel.navState = routeModel.navState.copy(destination = recentPlace)
private fun createNavigateAction(place: Place): Action {
recentPlace = place
return createAction(
carContext, R.drawable.chevron_right_24px,
onClickAction = {
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.SINGLE_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
)
) { obj: Any? ->
if (obj != null) {
navigateToPlace(place)
}
}
}
.build()
)
}
/**
* Creates an action to close the current view or template.
*/
private fun closeAction(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_close_white_24dp
)
)
.build()
)
.setOnClickListener {
return createAction(
carContext, R.drawable.ic_close_white_24dp,
onClickAction = {
navigationType = NavigationType.VIEW
invalidate()
}
.setFlags(FLAG_DEFAULT)
.build()
}
/**
* Creates an action to start the search screen.
*/
private fun searchAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.search_48px))
.setOnClickListener {
startSearchScreen()
}
.build()
)
}
/**
* Creates an action to start the settings screen.
*/
private fun settingsAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.settings_48px))
.setOnClickListener {
return createAction(
carContext, R.drawable.settings_48px,
FLAG_IS_PERSISTENT,
onClickAction = {
screenManager.push(SettingsScreen(carContext, navigationViewModel))
}
.build()
)
}
/**
* Creates an action to zoom in on the map.
*/
private fun zoomPlus(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_zoom_in_24
)
)
.build()
).setOnClickListener {
return createAction(
carContext, R.drawable.ic_zoom_in_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.handleScale(1)
invalidate()
}
.build()
)
}
/**
* Creates an action to zoom out on the map.
*/
private fun zoomMinus(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_zoom_out_24
)
)
.build()
).setOnClickListener {
return createAction(
carContext, R.drawable.ic_zoom_out_24,
onClickAction = {
surfaceRenderer.handleScale(-1)
invalidate()
}
.build()
)
}
/**
* Creates an action to enable map panning.
*/
private fun panAction(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_pan_24
)
)
.build()
).setOnClickListener {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
}
.build()
}
/**
* Pushes the search screen and handles the search result.
*/
private fun startSearchScreen() {
screenManager
.pushForResult(
SearchScreen(
carContext,
surfaceRenderer,
navigationViewModel
navigationViewModel,
recentPlaces
)
) { obj: Any? ->
if (obj != null) {
@@ -574,44 +475,12 @@ class NavigationScreen(
}
}
/**
* Pushes the search screen and handles the search result.
*/
private fun startPreviewScreen(route: String) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(route)
surfaceRenderer.setPreviewRouteData(routeModel)
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.SINGLE_ROUTE,
surfaceRenderer,
recentPlace,
navigationViewModel,
routeModel = routeModel
)
) { obj: Any? ->
if (obj != null) {
navigateToPlace(recentPlace)
} else {
routeModel.stopNavigation()
navigationType = NavigationType.VIEW
surfaceRenderer.clearRouteData()
invalidate()
}
}
}
/**
* Loads a route to the specified place and sets it as the destination.
*/
fun navigateToPlace(place: Place) {
val preview = navigationViewModel.previewRoute.value
navigationViewModel.previewRoute.value = ""
navigationType = NavigationType.NAVIGATION
val location = location(place.longitude, place.latitude)
navigationViewModel.saveRecent(carContext, place)
currentNavigationLocation = location
@@ -627,6 +496,7 @@ class NavigationScreen(
navigationViewModel.route.value = preview
}
routeModel.navState = routeModel.navState.copy(destination = place)
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
}
@@ -650,25 +520,27 @@ class NavigationScreen(
invalidate()
val mainThreadHandler = Handler(carContext.mainLooper)
mainThreadHandler.post {
object : CountDownTimer(2000, 1000) {
reRouteTimer?.cancel()
reRouteTimer = object : CountDownTimer(2000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
navigationType = NavigationType.NAVIGATION
reRoute(destination)
}
}.start()
}
reRouteTimer?.start()
}
}
/**
* Re-requests a route for the specified destination.
* Re-requests a route for the specified place.
*/
fun reRoute(destination: Place) {
val dest = location(destination.longitude, destination.latitude)
fun reRoute(place: Place) {
val destination = location(place.longitude, place.latitude)
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
dest,
destination,
surfaceRenderer.carOrientation
)
}
@@ -678,32 +550,58 @@ class NavigationScreen(
*/
fun updateTrip(location: Location) {
val current = LocalDateTime.now(ZoneOffset.UTC)
//checkRoute(current, location)
checkTraffic(current, location)
updateSpeedCamera(location)
routeModel.updateLocation(location, navigationViewModel)
checkArrival()
invalidate()
}
/**
* Checks if a new route is needed based on the time since the last update.
*/
private fun checkRoute(current: LocalDateTime, location: Location) {
val duration = Duration.between(current, lastRouteDate)
if (duration.abs().seconds > ROUTE_UPDATE) {
lastRouteDate = current
val destination = location(
routeModel.navState.destination.longitude,
routeModel.navState.destination.latitude
)
navigationViewModel.loadRoute(
carContext,
location,
destination,
surfaceRenderer.carOrientation
)
}
}
/**
* Checks if traffic data needs to be updated based on the time since the last update.
*/
fun checkTraffic(current: LocalDateTime, location: Location) {
val duration = Duration.between(current, lastTrafficDate)
if (duration.abs().seconds > TRAFFIC_UPDATE) {
if (showTraffic && duration.abs().seconds > TRAFFIC_UPDATE) {
lastTrafficDate = current
navigationViewModel.loadTraffic(carContext, location, surfaceRenderer.carOrientation)
}
updateSpeedCamera(location)
with(routeModel) {
updateLocation(location, navigationViewModel)
checkArrival()
}
invalidate()
}
/**
* Checks for arrival
*/
private fun RouteCarModel.checkArrival() {
if ((navState.maneuverType == Maneuver.TYPE_DESTINATION
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_STRAIGHT)
&& routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
fun checkArrival() {
if (routeModel.isArrival()
&& routeModel.routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
listener.stopNavigation()
settingsViewModel.onLastRouteChanged("")
navState = navState.copy(arrived = true)
routeModel.navState = routeModel.navState.copy(arrived = true)
surfaceRenderer.routeData.value = ""
navigationType = NavigationType.ARRIVAL
invalidate()
@@ -757,7 +655,7 @@ class NavigationScreen(
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.first()
val camera = sortedList.firstOrNull() ?: return
val bearingRoute = surfaceRenderer.lastLocation.bearingTo(location)
val bearingSpeedCamera = if (camera.tags.direction != null) {
try {
@@ -776,29 +674,94 @@ class NavigationScreen(
}
/**
* Checks for a specific permission and updates the view model upon grant.
* Handles the received route string.
* Starts navigation and invalidates the screen.
*/
fun checkPermission(permission: String) {
if (carContext.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
val permissions: MutableList<String?> = ArrayList()
permissions.add(permission)
val screenManager =
carContext.getCarService(ScreenManager::class.java)
screenManager
.push(
RequestPermissionScreen(
carContext,
permissionCheckCallback = {
screenManager.pop()
navigationViewModel.permissionGranted.value = true
},
permissions
)
)
} else {
navigationViewModel.permissionGranted.value = true
override fun onRouteReceived(route: String) {
if (route.isNotEmpty()) {
if (routeModel.isNavigating()) {
updateRoute(route)
} else {
prepareRoute(route)
}
invalidate()
}
}
/**
* Prepare route and start navigation
*/
private fun prepareRoute(route: String) {
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route)
if (routeModel.hasLegs()) {
settingsViewModel.onLastRouteChanged(route)
}
surfaceRenderer.setRouteData()
listener.startNavigation()
}
/**
* Update route and traffic data
*/
private fun updateRoute(route: String) {
val routeModel = RouteModel()
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route)
}
/**
* Checks if navigation is currently active.
*/
override fun isNavigating(): Boolean = routeModel.isNavigating()
/**
* Handles received traffic data and updates the surface renderer.
*/
override fun onTrafficReceived(traffic: Map<String, String>) {
if (traffic.isNotEmpty()) {
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>>()
cameras.forEach {
coordinates.add(listOf(it.lon, it.lat))
}
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
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()
}
}
/**

View File

@@ -3,6 +3,7 @@ package com.kouros.navigation.car.screen
import android.net.Uri
import android.text.Spannable
import android.text.SpannableString
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
@@ -16,7 +17,10 @@ import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
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
@@ -26,17 +30,13 @@ 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
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class PlaceListScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
private val category: String,
private val navigationViewModel: NavigationViewModel,
private val places: List<Place>
private val recentPlaces: List<Place>,
) : Screen(carContext) {
val routeModel = RouteCarModel()
@@ -45,45 +45,28 @@ class PlaceListScreen(
var mPlaces = mutableListOf<Place>()
val previewObserver = Observer<String> { route ->
if (route.isNotEmpty()) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(route)
surfaceRenderer.setPreviewRouteData(routeModel)
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.MULTI_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
routeModel = routeModel
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
}
val repository = getSettingsRepository(carContext)
private var routingEngine = 0
init {
// loadPlaces()
navigationViewModel.recentPlaces.value = emptyList()
navigationViewModel.previewRoute.value = ""
mPlaces.addAll(places)
navigationViewModel.previewRoute.observe(this, previewObserver)
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
navigationViewModel.recentPlaces.value = emptyList()
}
})
}
/**
* Returns the appropriate template based on the current navigation state.
*/
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage(carContext.getString(R.string.no_places))
mPlaces.forEach {
recentPlaces.filter { it.category == category }.forEach {
val street = if (it.street != null) {
it.street
} else {
@@ -104,13 +87,21 @@ class PlaceListScreen(
it.street,
// avatar = null
)
val location = location(place.longitude, place.latitude)
navigationViewModel.loadPreviewRoute(
carContext,
surfaceRenderer.lastLocation,
location,
surfaceRenderer.carOrientation
)
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.MULTI_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
if (category != CONTACTS) {
row.addText(SpannableString(" ").apply {
@@ -148,14 +139,11 @@ class PlaceListScreen(
.build()
}
private fun deleteAction(place: Place): Action = Action.Builder()
.setIcon(
RouteCarModel().createCarIcon(
carContext,
R.drawable.ic_close_white_24dp
)
)
.setOnClickListener {
/**
* Creates an Action to delete a place.
*/
private fun deleteAction(place: Place): Action =
createAction(carContext, R.drawable.ic_close_white_24dp) {
navigationViewModel.deletePlace(carContext, place)
CarToast.makeText(
carContext,
@@ -164,7 +152,7 @@ class PlaceListScreen(
mPlaces.remove(place)
invalidate()
}
.build()
fun contactIcon(avatar: Uri?, category: String?): CarIcon {
if (category == RECENT || avatar == null) {

View File

@@ -1,9 +1,11 @@
package com.kouros.navigation.car.screen
import android.Manifest.permission
import android.content.pm.PackageManager
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.MessageTemplate
@@ -46,7 +48,6 @@ class RequestPermissionScreen(
).show()
if (!approved!!.isEmpty()) {
permissionCheckCallback.onPermissionGranted()
//mContactsPermissionCheckCallback.onPermissionGranted()
finish()
}
}
@@ -62,4 +63,31 @@ class RequestPermissionScreen(
.addAction(action)
.build()
}
}
/**
* Checks for a specific permission and return the result of the check.
*/
fun checkPermission(carContext: CarContext, permission: String) : Boolean {
if (carContext.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
val permissions: MutableList<String?> = ArrayList()
permissions.add(permission)
val screenManager =
carContext.getCarService(ScreenManager::class.java)
screenManager
.push(
RequestPermissionScreen(
carContext,
permissionCheckCallback = {
screenManager.pop()
return@RequestPermissionScreen
},
permissions
)
)
} else {
return true
}
return false
}

View File

@@ -3,6 +3,7 @@ package com.kouros.navigation.car.screen
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.activity.OnBackPressedCallback
import androidx.annotation.DrawableRes
import androidx.car.app.CarContext
@@ -11,6 +12,7 @@ import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
@@ -27,26 +29,34 @@ import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Duration
import kotlin.math.min
/** Creates a screen using the new [androidx.car.app.navigation.model.MapWithContentTemplate] */
/** Creates a screen using the new [MapWithContentTemplate] */
class RoutePreviewScreen(
carContext: CarContext,
private var routeType: RoutePreviewType,
private var surfaceRenderer: SurfaceRenderer,
private var destination: Place,
private val navigationViewModel: NavigationViewModel,
private val routeModel: RouteCarModel
) :
Screen(carContext) {
private var isFavorite = false
@@ -54,19 +64,68 @@ class RoutePreviewScreen(
val maxListItems: Int = 3
val navigationUtils = NavigationUtils(carContext)
val repository = getSettingsRepository(carContext)
var routingEngine = -1
var routeSelected = false
val routeModel = RouteCarModel()
var loading = true
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
invalidate()
}
}
val observer = Observer<String> { route ->
if (route.isNotEmpty() && routingEngine != -1) {
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(route)
surfaceRenderer.setPreviewRouteData(routeModel)
loading = false
invalidate()
}
}
val trafficObserver = Observer<Map<String, String>> { traffic ->
if (traffic.isNotEmpty()) {
navigationViewModel.traffic.value = emptyMap()
surfaceRenderer.setTrafficData(traffic)
}
}
init {
navigationViewModel.previewRoute.observe(this, observer)
carContext.onBackPressedDispatcher.addCallback(this, backPressedCallback)
navigationViewModel.traffic.observe(this, trafficObserver)
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
navigationViewModel.previewRoute.value = ""
}
})
repository.trafficFlow.asLiveData().observe(this, Observer {
if (it) {
getTraffic()
}
})
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
lifecycleScope.launch {
navigationViewModel.loadPreviewRoute(
carContext,
surfaceRenderer.lastLocation,
location(destination.longitude, destination.latitude),
surfaceRenderer.carOrientation
)
}
}
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
if (carContext.getCarAppApiLevel() > CarAppApiLevels.LEVEL_1) {
val listLimit = min(
@@ -78,7 +137,9 @@ class RoutePreviewScreen(
)
var index = 0
routeModel.route.routes.forEach { route ->
itemListBuilder.addItem(createRow(route, index++))
if (index < listLimit) {
itemListBuilder.addItem(createRow(route, index++))
}
}
}
@@ -106,12 +167,16 @@ class RoutePreviewScreen(
CarText.Builder("Wait")
.build()
}
val content = if (routeType == RoutePreviewType.MULTI_ROUTE) {
ListTemplate.Builder()
val listContent = ListTemplate.Builder()
.setHeader(header.build())
.setSingleList(itemListBuilder.build())
if (loading) {
listContent.setLoading(true)
} else {
listContent.setSingleList(itemListBuilder.build())
}
.build()
listContent.build()
} else {
val navigateActionIcon: CarIcon = CarIcon.Builder(
IconCompat.createWithResource(
@@ -123,75 +188,78 @@ class RoutePreviewScreen(
carContext, R.drawable.alt_route_48px
)
).build()
val navigateAction = Action.Builder()
.setFlags(FLAG_DEFAULT)
.setIcon(navigateActionIcon)
.setOnClickListener { onNavigate(routeModel.navState.currentRouteIndex) }
.build()
val selectRouteAction = Action.Builder()
.setIcon(selectRouteIcon)
.setOnClickListener {
routeType = RoutePreviewType.MULTI_ROUTE
invalidate()
}
.build()
MessageTemplate.Builder(
message
)
val navigateAction =
createAction(carContext, R.drawable.navigation_48px, FLAG_DEFAULT,{
onNavigate(routeModel.navState.currentRouteIndex)
})
val selectRouteAction = createAction(carContext, R.drawable.alt_route_48px, FLAG_IS_PERSISTENT, {
routeType = RoutePreviewType.MULTI_ROUTE
invalidate()
})
val listContent = MessageTemplate.Builder(message)
.setHeader(header.build())
.addAction(navigateAction)
.addAction(selectRouteAction)
.setLoading(message.toString() == "Wait")
.build()
if (loading) {
listContent.setLoading(true)
}
listContent.build()
}
val template = MapWithContentTemplate.Builder()
.setContentTemplate(content)
.setMapController(
MapController.Builder().setMapActionStrip(
getMapActionStrip()
).build()
mapActionStrip(ViewStyle.PREVIEW, {zoomPlus()}, { zoomMinus()}, {
zoomMinus()
} )).build()
)
if (routeType == RoutePreviewType.MULTI_ROUTE && !routeSelected) {
template.setActionStrip(createActionStripBuilder().build())
template.setActionStrip(createActionStrip {
createAction(
carContext, R.drawable.navigation_48px,
onClickAction = {
onNavigate(routeModel.navState.currentRouteIndex)
}
)
})
}
return template.build()
}
private fun createActionStripBuilder(): ActionStrip.Builder {
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
actionStripBuilder.addAction(
navigateAction()
)
return actionStripBuilder
}
private fun navigateAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.navigation_48px))
.setFlags(FLAG_DEFAULT)
.setOnClickListener {
onNavigate(routeModel.navState.currentRouteIndex)
private fun zoomPlus(): Action {
return createAction(
carContext, R.drawable.ic_zoom_in_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.handleScale(1)
invalidate()
}
.build()
)
}
private fun favoriteAction(): Action = Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (isFavorite)
R.drawable.ic_favorite_filled_white_24dp
else
R.drawable.ic_favorite_white_24dp
)
)
.build()
/**
* Creates an action to zoom out on the map.
*/
private fun zoomMinus(): Action {
return createAction(
carContext, R.drawable.ic_zoom_out_24,
onClickAction = {
surfaceRenderer.handleScale(-1)
invalidate()
}
)
.setOnClickListener {
}
private fun favoriteAction(): Action =
createAction(
carContext, if (isFavorite)
R.drawable.ic_favorite_filled_white_24dp
else
R.drawable.ic_favorite_white_24dp
, FLAG_IS_PERSISTENT,
) {
isFavorite = !isFavorite
CarToast.makeText(
carContext,
@@ -208,26 +276,17 @@ class RoutePreviewScreen(
navigationViewModel.saveFavorite(carContext, destination)
invalidate()
}
.build()
private fun deleteFavoriteAction(): Action = Action.Builder()
.setOnClickListener {
private fun deleteFavoriteAction(): Action =
createAction(carContext, R.drawable.heart_minus_48px, FLAG_IS_PERSISTENT,{
if (isFavorite) {
navigationViewModel.deleteFavorite(carContext, destination)
}
isFavorite = !isFavorite
finish()
}
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.heart_minus_48px
)
)
.build()
)
.build()
})
private fun createRouteText(route: Routes): CarText {
val time = route.summary.duration
@@ -245,21 +304,13 @@ class RoutePreviewScreen(
}
private fun createRow(route: Routes, index: Int): Row {
val navigateActionIcon: CarIcon = CarIcon.Builder(
IconCompat.createWithResource(
carContext, R.drawable.navigation_48px
)
).build()
val navigateAction = Action.Builder()
.setFlags(FLAG_DEFAULT)
.setIcon(navigateActionIcon)
.setOnClickListener { this.onNavigate(index) }
.build()
val navigateAction = createAction(carContext, R.drawable.navigation_48px ) {
this.onNavigate(index)
}
val routeText = createRouteText(route)
var street = ""
var maxDistance = 0.0
routeModel.route.routes[index].legs.first().steps.forEach {
routeModel.steps.forEach {
if (it.distance > maxDistance) {
maxDistance = it.distance
street = it.street
@@ -271,9 +322,9 @@ class RoutePreviewScreen(
.setOnClickListener { onRouteSelected(index) }
.addText(street)
.addAction(navigateAction)
if (route.summary.trafficDelay > 60) {
row.addText(createDelay(route))
row.setImage(NavigationUtils(carContext).createCarIcon(R.drawable.traffic_jam_48px))
}
return row.build()
}
@@ -294,6 +345,7 @@ class RoutePreviewScreen(
)
return delayBuilder
}
private fun onNavigate(index: Int) {
destination.routeIndex = index
setResult(destination)
@@ -303,30 +355,19 @@ class RoutePreviewScreen(
private fun onRouteSelected(index: Int) {
routeModel.navState = routeModel.navState.copy(currentRouteIndex = index)
surfaceRenderer.setPreviewRouteData(routeModel)
surfaceRenderer.updateTrafficData(routeModel)
routeSelected = true
invalidate()
}
fun getMapActionStrip(): ActionStrip {
return ActionStrip.Builder()
.addAction(
createToastAction(R.drawable.ic_zoom_in_24)
)
.addAction(
createToastAction(R.drawable.ic_zoom_out_24)
)
.addAction(Action.PAN)
.build()
private fun getTraffic() {
navigationViewModel.loadTraffic(
carContext,
surfaceRenderer.lastLocation,
surfaceRenderer.carOrientation
)
}
private fun createToastAction(
@DrawableRes iconRes: Int
): Action {
return Action.Builder()
.setOnClickListener { surfaceRenderer.handleScale(-1) }
.setIcon(navigationUtils.createCarIcon(iconRes))
.build()
}
}
enum class RoutePreviewType {

View File

@@ -0,0 +1,62 @@
package com.kouros.navigation.car.screen
import androidx.annotation.DrawableRes
import androidx.car.app.CarContext
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.core.graphics.drawable.IconCompat
import com.kouros.navigation.car.ViewStyle
fun createActionStrip(executeAction: () -> Action): ActionStrip {
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
actionStripBuilder.addAction(
executeAction()
)
return actionStripBuilder.build()
}
fun createActionStripBuilder(action1: () -> Action, action2: () -> Action): ActionStrip.Builder {
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
actionStripBuilder.addAction(
action1()
)
actionStripBuilder.addAction(
action2()
)
return actionStripBuilder
}
/**
* Creates an ActionStrip builder for map-related actions like zoom and pan.
*/
fun mapActionStrip(viewStyle: ViewStyle, zoomPlus: () -> Action, zoomMinus: () -> Action , panAction: () -> Action): ActionStrip {
val actionStripBuilder = ActionStrip.Builder()
.addAction(zoomPlus())
.addAction(zoomMinus())
if (viewStyle == ViewStyle.PAN_VIEW) {
actionStripBuilder
.addAction(
panAction()
)
}
return actionStripBuilder.build()
}
/**
* Creates an action to do something.
*/
fun createAction(carContext: CarContext, @DrawableRes iconRes: Int, flag: Int = FLAG_DEFAULT, onClickAction: () -> Unit): Action {
return Action.Builder()
.setIcon(createCarIcon(carContext, iconRes))
.setFlags(flag)
.setOnClickListener {
onClickAction()
}
.build()
}
fun createCarIcon(carContext: CarContext, @DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()
}

View File

@@ -28,6 +28,7 @@ class SearchScreen(
carContext: CarContext,
private var surfaceRenderer: SurfaceRenderer,
private val navigationViewModel: NavigationViewModel,
private val recentPlaces: List<Place>,
) : Screen(carContext) {
var isSearchComplete: Boolean = false
@@ -44,54 +45,16 @@ class SearchScreen(
lateinit var searchResult: List<SearchResult>
val observer = Observer<List<SearchResult>> { newSearch ->
searchResult = newSearch
invalidate()
}
val observerRecentPlaces = Observer<List<Place>> { newPlaces ->
if (newPlaces.isNotEmpty()) {
screenManager
.pushForResult(
PlaceListScreen(
carContext,
surfaceRenderer,
RECENT,
navigationViewModel,
newPlaces
)
) { obj: Any? ->
surfaceRenderer.clearRouteData()
if (obj != null) {
setResult(obj)
finish()
}
}
if (newSearch.isNotEmpty()) {
navigationViewModel.searchPlaces.value = emptyList()
searchResult = newSearch
invalidate()
}
}
val observerFavorites = Observer<List<Place>> { newPlaces ->
screenManager
.pushForResult(
PlaceListScreen(
carContext,
surfaceRenderer,
FAVORITES,
navigationViewModel,
newPlaces
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
init {
navigationViewModel.searchPlaces.observe(this, observer)
navigationViewModel.recentPlaces.observe(this, observerRecentPlaces)
navigationViewModel.favorites.observe(this, observerFavorites)
}
override fun onGetTemplate(): Template {
@@ -124,20 +87,22 @@ class SearchScreen(
}
}
} else {
if (it.id == RECENT) {
navigationViewModel.loadRecentPlaces(
carContext,
surfaceRenderer.lastLocation,
surfaceRenderer.carOrientation
)
}
if (it.id == FAVORITES) {
navigationViewModel.loadFavorites(
carContext,
surfaceRenderer.lastLocation,
surfaceRenderer.carOrientation
)
}
screenManager
.pushForResult(
PlaceListScreen(
carContext,
surfaceRenderer,
it.id,
navigationViewModel,
recentPlaces
)
) { obj: Any? ->
surfaceRenderer.viewStyle = ViewStyle.VIEW
if (obj != null) {
setResult(obj)
finish()
}
}
}
}
.setBrowsable(true)

View File

@@ -10,10 +10,7 @@ import com.kouros.navigation.data.overpass.Elements
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
@@ -29,9 +26,6 @@ interface NavigationObserverCallback {
/** Called when max speed is updated */
fun onMaxSpeedReceived(speed: Int)
/** Called when a preview route is received and navigation should start */
fun onPreviewRouteReceived(route: String)
/** Called to request UI invalidation/refresh */
fun invalidateScreen()

View File

@@ -12,12 +12,10 @@ class NavigationObserverManager(
) {
val routeObserver = RouteObserver(callback)
val recentPlaceObserver = RecentPlaceObserver(callback)
val trafficObserver = TrafficObserver(callback)
val placeSearchObserver = PlaceSearchObserver(callback)
val speedCameraObserver = SpeedCameraObserver(callback)
val maxSpeedObserver = MaxSpeedObserver(callback)
val previewObserver = PreviewRouteObserver(callback)
/**
* Attaches all observers to the ViewModel.
@@ -26,11 +24,9 @@ class NavigationObserverManager(
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)
viewModel.previewRoute.observe(screen, previewObserver)
}
/**

View File

@@ -1,14 +0,0 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
class PreviewRouteObserver(
private val callback: NavigationObserverCallback
) : Observer<String> {
override fun onChanged(value: String) {
if (value.isNotEmpty()) {
callback.onPreviewRouteReceived(value)
}
}
}

View File

@@ -1,18 +0,0 @@
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

@@ -23,6 +23,8 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
private var showTraffic = false
private var tripSuggestion = false
val settingsViewModel = getSettingsViewModel(carContext)
override fun onGetTemplate(): Template {
@@ -34,7 +36,12 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
buildingToggleState = it
invalidate()
}
settingsViewModel.tripSuggestion.asLiveData().observe(this) {
tripSuggestion = it
invalidate()
}
val listBuilder = ItemList.Builder()
val buildingToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onShow3DChanged(checked)
@@ -49,6 +56,13 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
}.setChecked(showTraffic).build()
listBuilder.addItem(buildRowForTemplate(R.string.traffic, trafficToggle))
val tripSuggestionToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onTripSuggestion(checked)
tripSuggestion = !tripSuggestion
}.setChecked(tripSuggestion).build()
listBuilder.addItem(buildRowForTemplate(R.string.trip_suggestion, tripSuggestionToggle))
listBuilder.addItem(
buildRowForScreenTemplate(
DarkModeSettings(carContext),

View File

@@ -0,0 +1,133 @@
package com.kouros.navigation.car.screen
import androidx.car.app.testing.ScreenController
import androidx.car.app.testing.TestCarContext
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ApplicationProvider
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.model.NavigationViewModel
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
import com.google.common.truth.Truth.assertThat
import com.kouros.navigation.data.NavigationState
import com.kouros.navigation.data.Place
import com.kouros.navigation.model.RouteCalculator
@RunWith(RobolectricTestRunner::class)
class NavigationScreenTest {
private lateinit var testCarContext: TestCarContext
@Mock
private lateinit var mockSurfaceRenderer: SurfaceRenderer
@Mock
private lateinit var mockRouteModel: RouteCarModel
@Mock
private lateinit var mockListener: NavigationListener
@Mock
private lateinit var mockViewModel: NavigationViewModel
private lateinit var navigationScreen: NavigationScreen
private lateinit var screenController: ScreenController
@Mock
private lateinit var mockRouteCalculator: RouteCalculator
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
testCarContext = TestCarContext.createCarContext(ApplicationProvider.getApplicationContext())
// Setup initial state
`when`(mockRouteModel.isNavigating()).thenReturn(true)
`when`(mockViewModel.route).thenReturn(MutableLiveData())
`when`(mockViewModel.traffic).thenReturn(MutableLiveData())
`when`(mockViewModel.recentPlaces).thenReturn(MutableLiveData())
`when`(mockViewModel.placeLocation).thenReturn(MutableLiveData())
`when`(mockViewModel.speedCameras).thenReturn(MutableLiveData())
`when`(mockViewModel.maxSpeed).thenReturn(MutableLiveData())
`when`(mockViewModel.previewRoute).thenReturn(MutableLiveData())
`when`(mockSurfaceRenderer.routeData).thenReturn(MutableLiveData())
navigationScreen = NavigationScreen(
testCarContext,
mockSurfaceRenderer,
mockRouteModel,
mockListener,
mockViewModel
)
screenController = ScreenController(navigationScreen)
}
@Test
fun onGetTemplate_whenNavigating_returnsNavigationTemplate() {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
// Act
val template = screenController.screen
// Assert
assertThat(template).isInstanceOf(NavigationScreen::class.java)
}
@Test
fun startNavigation_updatesState() {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.NAVIGATION)
}
@Test
fun stopNavigation_updatesState() {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
// Act
navigationScreen.stopNavigation()
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.VIEW)
}
@Test
fun rerouteNavigation_updatesState() {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
// Act
navigationScreen.calculateNewRoute(Place())
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.REROUTE)
}
@Test
fun arrivalNavigation_updatesState() {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
`when`(mockRouteModel.isArrival()).thenReturn(true)
`when`(mockRouteModel.routeCalculator).thenReturn(mockRouteCalculator)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(19.0)
`when`(mockRouteModel.navState).thenReturn(NavigationState())
// Act
navigationScreen.checkArrival()
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.ARRIVAL)
}
}

View File

@@ -41,30 +41,6 @@ class ObserversTest {
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)
@@ -119,15 +95,15 @@ class ObserversTest {
}
// Helper methods
private fun createTestPlace(): Place {
return Place(
private fun createTestPlace():List<Place> {
return listOf(Place(
name = "Test Place",
street = "Test Street",
city = "Test City",
latitude = 52.0,
longitude = 10.0,
category = Constants.FAVORITES
)
))
}
private fun createTestSearchResult(): SearchResult {

View File

@@ -130,6 +130,8 @@ object Constants {
const val TRAFFIC_UPDATE = 300
const val ROUTE_UPDATE = 60
const val INSTRUCTION_DISTANCE = 50
const val GMS_CAR_SPEED_PERMISSION = "com.google.android.gms.permission.CAR_SPEED"

View File

@@ -1,29 +1,7 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.data
import android.content.Context
import android.location.Location
import com.google.gson.GsonBuilder
import com.kouros.data.R
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.osrm.OsrmResponse
import com.kouros.navigation.data.osrm.OsrmRoute
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.GeoUtils.calculateSquareRadius
import java.net.Authenticator
import java.net.HttpURLConnection
@@ -41,7 +19,7 @@ abstract class NavigationRepository {
abstract fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
destination: Location,
carOrientation: Float,
searchFilter: SearchFilter
): String
@@ -59,7 +37,7 @@ abstract class NavigationRepository {
}
fun searchPlaces(search: String, location: Location): String {
val box = calculateSquareRadius(location.latitude, location.longitude, 100.0)
val box = calculateSquareRadius(location.latitude, location.longitude, 800.0)
val viewbox = "&bounded=1&viewbox=${box}"
return fetchUrl(
"${nominatimUrl}search?q=$search&format=jsonv2&addressdetails=true$viewbox",

View File

@@ -53,6 +53,8 @@ class DataStoreManager(private val context: Context) {
val TRAFFIC = booleanPreferencesKey("Traffic")
val TRIP_SUGGESTION = booleanPreferencesKey("TripSuggestion")
}
// Read values
@@ -129,6 +131,11 @@ class DataStoreManager(private val context: Context) {
preferences[PreferencesKeys.TRAFFIC] == true
}
val tripSuggestionFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.TRIP_SUGGESTION] == true
}
// Save values
suspend fun setShow3D(enabled: Boolean) {
context.dataStore.edit { preferences ->
@@ -207,4 +214,10 @@ class DataStoreManager(private val context: Context) {
preferences[PreferencesKeys.TRAFFIC] = enabled
}
}
suspend fun setTripSuggestion(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.TRIP_SUGGESTION] = enabled
}
}
}

View File

@@ -12,4 +12,5 @@ data class Step(
val distance: Double = 0.0,
val street : String = "",
val intersection: List<Intersection> = mutableListOf(),
val countryCode : String = ""
)

View File

@@ -19,9 +19,9 @@ const val tomtomTrafficUrl = "https://api.tomtom.com/traffic/services/5/incident
private const val tomtomFields =
"{incidents{type,geometry{type,coordinates},properties{iconCategory,events{description}}}}"
const val useAsset = false
const val useLocal = false
const val useAssetTraffic = false
const val useLocalTraffic = false
class TomTomRepository : NavigationRepository() {
@@ -32,12 +32,11 @@ class TomTomRepository : NavigationRepository() {
carOrientation: Float,
searchFilter: SearchFilter
): String {
if (useAsset) {
val resourceId: Int = context.resources
.getIdentifier("tomtom_routing", "raw", context.packageName)
val routeJson = context.resources.openRawResource(resourceId)
val routeJsonString = routeJson.bufferedReader().use { it.readText() }
return routeJsonString
if (useLocal) {
return fetchUrl(
"https://kouros-online.de/tomtom_routing.json",
false
)
}
var filter = ""
if (searchFilter.avoidMotorway) {
@@ -76,11 +75,11 @@ class TomTomRepository : NavigationRepository() {
return ""
}
val bbox = calculateSquareRadius(location.latitude, location.longitude, 15.0)
return if (useAssetTraffic) {
val resourceId: Int = context.resources
.getIdentifier("tomtom_traffic", "raw", context.packageName)
val trafficJson = context.resources.openRawResource(resourceId)
trafficJson.bufferedReader().use { it.readText() }
return if (useLocalTraffic) {
fetchUrl(
"https://kouros-online.de/tomtom_traffic.json",
false
)
} else {
val trafficResult = fetchUrl(
"$tomtomTrafficUrl?key=$tomtomApiKey&bbox=$bbox&fields=$tomtomFields&language=en-GB&timeValidityFilter=present",

View File

@@ -62,7 +62,9 @@ class TomTomRoute {
lastPointIndex = instruction.pointIndex
val intersections = mutableListOf<Intersection>()
route.sections?.forEach { section ->
if (section.sectionType == "LANES" && section.startPointIndex <= lastPointIndex && section.endPointIndex >= lastPointIndex) {
if (section.sectionType == "LANES" && section.startPointIndex <= lastPointIndex
&& section.endPointIndex >= lastPointIndex
) {
val lanes = mutableListOf<Lane>()
var startIndex = 0
var lastLane: Lane? = null
@@ -87,25 +89,25 @@ class TomTomRoute {
lastLane = lane
}
intersections.add(Intersection(waypoints[startIndex], lanes))
}
stepDistance =
route.guidance.instructions[index].routeOffsetInMeters - stepDistance
stepDuration =
route.guidance.instructions[index].travelTimeInSeconds - stepDuration
val step = Step(
index = stepIndex,
street = street,
distance = stepDistance,
duration = stepDuration,
maneuver = maneuver,
intersection = intersections
)
stepDistance = route.guidance.instructions[index].routeOffsetInMeters.toDouble()
stepDuration = route.guidance.instructions[index].travelTimeInSeconds.toDouble()
steps.add(step)
stepIndex += 1
}
stepDistance =
route.guidance.instructions[index].routeOffsetInMeters - stepDistance
stepDuration =
route.guidance.instructions[index].travelTimeInSeconds - stepDuration
val step = Step(
index = stepIndex,
street = street,
distance = stepDistance,
duration = stepDuration,
maneuver = maneuver,
intersection = intersections,
countryCode = lastInstruction.countryCode
)
stepDistance = route.guidance.instructions[index].routeOffsetInMeters.toDouble()
stepDuration = route.guidance.instructions[index].travelTimeInSeconds.toDouble()
steps.add(step)
stepIndex += 1
}
legs.add(Leg(steps))
val routeGeoJson = createLineStringCollection(waypoints)

View File

@@ -16,7 +16,7 @@ import com.kouros.navigation.data.StepData
import java.util.Collections
import java.util.Locale
class IconMapper() {
class IconMapper {
fun maneuverIcon(routeManeuverType: Int): Int {
var currentTurnIcon = R.drawable.ic_turn_name_change

View File

@@ -5,6 +5,8 @@ import android.content.Context
import android.location.Location
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -60,11 +62,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
MutableLiveData()
}
/** LiveData containing list of favorite saved places */
val favorites: MutableLiveData<List<Place>> by lazy {
MutableLiveData()
}
/** LiveData containing search results from Nominatim geocoding */
val searchPlaces: MutableLiveData<List<SearchResult>> by lazy {
MutableLiveData()
@@ -147,7 +144,8 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
var id: Long = 0
if (rp.isNotEmpty()) {
for (place in places.places) {
if (place.category.equals(Constants.RECENT)) {
if (place.category.equals(Constants.RECENT)
|| place.category.equals(Constants.FAVORITES)) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
@@ -172,43 +170,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
}
/**
* Loads favorite places from Preferences and calculates distances.
* Posts the sorted list to favorites LiveData.
*/
fun loadFavorites(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java)
val pl = mutableListOf<Place>()
if (rp.isNotEmpty()) {
for (place in recentPlaces.places) {
if (place.category.equals(Constants.FAVORITES)) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
pl.add(place)
}
}
}
favorites.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
/**
* Calculates a route between current location and destination.
* Posts the route JSON to route LiveData.
@@ -216,7 +177,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
fun loadRoute(
context: Context,
currentLocation: Location,
location: Location,
destination: Location,
carOrientation: Float
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -225,7 +186,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
repository.getRoute(
context,
currentLocation,
location,
destination,
carOrientation,
getSearchFilter(context)
)
@@ -296,7 +257,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
currentLocation: Location,
location: Location,
carOrientation: Float
) {
): String? {
viewModelScope.launch(Dispatchers.IO) {
try {
previewRoute.postValue(
@@ -312,8 +273,10 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
e.printStackTrace()
}
}
return previewRoute.value
}
/**
* Loads device contacts with addresses and converts to Place objects.
* Posts results to contactAddress LiveData.

View File

@@ -13,7 +13,7 @@ import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.utils.location
import kotlin.math.absoluteValue
open class RouteModel {
@@ -33,6 +33,9 @@ open class RouteModel {
val currentStep: Step
get() = navState.route.nextStep(0)
val steps: List<Step>
get() = curLeg.steps
fun startNavigation(routeString: String) {
navState = navState.copy(
route = Route.Builder()
@@ -145,7 +148,20 @@ open class RouteModel {
)
}
/**
* Checks for navigating
*/
fun isNavigating(): Boolean {
return navState.navigating
}
/**
* Checks for arrival
*/
fun isArrival(): Boolean {
return navState.maneuverType == Maneuver.TYPE_DESTINATION
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_STRAIGHT
}
}

View File

@@ -90,6 +90,12 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
false
)
val tripSuggestion = repository.tripSuggestionFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
false
)
fun onShow3DChanged(enabled: Boolean) {
viewModelScope.launch { repository.setShow3D(enabled) }
}
@@ -138,4 +144,8 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
fun onTraffic(enabled: Boolean) {
viewModelScope.launch { repository.setTraffic(enabled) }
}
fun onTripSuggestion(enabled: Boolean) {
viewModelScope.launch { repository.setTripSuggestion(enabled) }
}
}

View File

@@ -44,6 +44,9 @@ class SettingsRepository(
val trafficFlow: Flow<Boolean> =
dataStoreManager.trafficFlow
val tripSuggestionFlow: Flow<Boolean> =
dataStoreManager.tripSuggestionFlow
suspend fun setShow3D(enabled: Boolean) {
dataStoreManager.setShow3D(enabled)
}
@@ -95,4 +98,8 @@ class SettingsRepository(
suspend fun setTraffic(enabled: Boolean) {
dataStoreManager.setTraffic(enabled)
}
suspend fun setTripSuggestion(enabled: Boolean) {
dataStoreManager.setTripSuggestion(enabled)
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M70,880Q58,880 49,871.5Q40,863 40,850L40,517L128,308Q133,295 144,287.5Q155,280 169,280L552,280Q566,280 577,287.5Q588,295 593,308L680,517L680,850Q680,863 671.5,871.5Q663,880 650,880L609,880Q597,880 588,871.5Q579,863 579,850L579,800L141,800L141,850Q141,863 132.5,871.5Q124,880 111,880L70,880ZM130,458L591,458L542,340L179,340L130,458ZM246.5,664Q261,649 261,629Q261,608 246.5,593Q232,578 211,578Q190,578 175,593Q160,608 160,629Q160,649 175,664Q190,679 211,679Q232,679 246.5,664ZM545.5,664Q560,649 560,629Q560,608 545.5,593Q531,578 510,578Q489,578 474,593Q459,608 459,629Q459,649 474,664Q489,679 510,679Q531,679 545.5,664ZM740,757L740,413L659,220L237,220L251,188Q256,175 267,167.5Q278,160 292,160L669,160Q683,160 694.5,167.5Q706,175 711,188L800,401L800,727Q800,740 791.5,748.5Q783,757 770,757L740,757ZM860,634L860,290L780,100L360,100L374,68Q379,55 390,47.5Q401,40 415,40L790,40Q804,40 815.5,47.5Q827,55 832,68L920,278L920,604Q920,617 911.5,625.5Q903,634 890,634L860,634Z"/>
</vector>

View File

@@ -65,4 +65,5 @@
<string name="no_categories">Keine Kategorien</string>
<string name="general">Allgemein</string>
<string name="traffic">Verkehr anzeigen</string>
<string name="trip_suggestion">Fahrten-Vorschläge</string>
</resources>

View File

@@ -49,4 +49,5 @@
<string name="no_categories">Δεν υπάρχουν κατηγορίες</string>
<string name="general">Γενικά</string>
<string name="traffic">Εμφάνιση κίνησης</string>
<string name="trip_suggestion">Προτάσεις διαδρομής</string>
</resources>

View File

@@ -49,4 +49,5 @@
<string name="no_categories">Brak kategorii do wyświetlenia</string>
<string name="general">Ogólne</string>
<string name="traffic">Pokaż natężenie ruchu</string>
<string name="trip_suggestion">Sugestie dotyczące podróży</string>
</resources>

View File

@@ -52,4 +52,5 @@
<string name="no_categories">No categories to show</string>
<string name="general">General</string>
<string name="traffic">Show traffic</string>
<string name="trip_suggestion">Trip suggestions</string>
</resources>

View File

@@ -86,21 +86,6 @@ class RouteModelTest {
assert(routeModel.navState.currentLocation.longitude == 11.57936)
}
@Test
fun `currentStep returns StepData `() {
val stepData = routeModel.currentStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Milbertshofener Straße")
}
@Test
fun `nextStep returns StepData `() {
routeModel.currentStep()
val stepData = routeModel.nextStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Bad-Soden-Straße")
}
@Test
fun `stopNavigation updates route and sets navigating to false `() {
routeModel.stopNavigation()

View File

@@ -4,25 +4,25 @@ androidGpxParser = "2.3.1"
androidSdkTurf = "6.0.1"
datastore = "1.2.1"
gradle = "9.1.0"
koinAndroid = "4.1.1"
koinAndroidxCompose = "4.1.1"
koinComposeViewmodel = "4.1.1"
koinCore = "4.1.1"
kotlin = "2.3.10"
koinAndroid = "4.2.0"
koinAndroidxCompose = "4.2.0"
koinComposeViewmodel = "4.2.0"
koinCore = "4.2.0"
kotlin = "2.3.20"
coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
kotlinxSerializationJson = "1.10.0"
lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.02.01"
composeBom = "2026.03.00"
appcompat = "1.7.1"
material = "1.13.0"
carApp = "1.7.0"
androidx-car = "1.7.0"
materialIconsExtended = "1.7.8"
mockitoCore = "5.23.0"
mockitoKotlin = "6.2.3"
mockitoKotlin = "6.3.0"
rules = "1.7.0"
runner = "1.7.0"
material3 = "1.4.0"
@@ -44,6 +44,9 @@ foundationLayout = "1.10.5"
datastorePreferences = "1.2.1"
datastoreCore = "1.2.1"
monitor = "1.8.0"
robolectric = "4.16.1"
truth = "1.4.5"
testCore = "1.7.0"
[libraries]
android-gpx-parser = { module = "com.github.ticofab:android-gpx-parser", version.ref = "androidGpxParser" }
@@ -69,6 +72,7 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koinCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" }
androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", version.ref = "carApp" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" }
@@ -91,6 +95,9 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" }
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
google-truth = { module = "com.google.truth:truth", version.ref = "truth" }
androidx-test-core = { module = "androidx.test:core", version.ref = "testCore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }