Categories

This commit is contained in:
Dimitris
2026-03-09 18:55:13 +01:00
parent 8c103a1f96
commit 61ce09f393
38 changed files with 864 additions and 338 deletions

View File

@@ -55,7 +55,7 @@ class RouteModelTest {
fun checkRoute() {
assertEquals(true, routeModel.isNavigating())
assertEquals(routeModel.curRoute.summary.distance, 11116.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1148.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1483.0, 10.0)
}
@Test
@@ -66,7 +66,7 @@ class RouteModelTest {
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.instruction, "Silcherstraße")
assertEquals(stepData.leftStepDistance, 30.0, 1.0)
assertEquals(stepData.leftStepDistance, 25.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.instruction, "Schmalkaldener Straße")
@@ -114,7 +114,7 @@ class RouteModelTest {
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(stepData.instruction, "Schenkendorfstraße")
assertEquals(stepData.leftStepDistance, 170.0, 1.0)
assertEquals(stepData.leftStepDistance, 170.0, 10.0)
assertEquals(stepData.lane.size, 4)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.last().valid, false)

View File

@@ -29,6 +29,7 @@ import com.kouros.navigation.car.screen.RequestPermissionScreen
import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.GMS_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.INSTRUCTION_DISTANCE
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG
@@ -40,6 +41,7 @@ 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.launch
@@ -77,6 +79,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
var autoDriveEnabled = false
val simulation = Simulation()
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
@@ -205,8 +208,13 @@ class NavigationSession : Session(), NavigationScreen.Listener {
navigationManager.setNavigationManagerCallback(object : NavigationManagerCallback {
override fun onAutoDriveEnabled() {
// Called when the app should simulate navigation (e.g., for testing)
// Implement your simulation logic here
deviceLocationManager.stopLocationUpdates()
autoDriveEnabled = true
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
) { location ->
updateLocation(location)
}
}
override fun onStopNavigation() {
@@ -214,6 +222,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
// Stop turn-by-turn logic and clean up
routeModel.stopNavigation()
autoDriveEnabled = false
deviceLocationManager.startLocationUpdates()
}
})
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
@@ -240,6 +249,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
}
)
textToSpeechManager = TextToSpeechManager(carContext)
val repository = getSettingsRepository(carContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
@@ -342,9 +352,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Handles route snapping, deviation detection for rerouting, and map updates.
*/
fun updateLocation(location: Location) {
if (routeModel.navState.carConnection == CarConnection.CONNECTION_TYPE_PROJECTION ) {
surfaceRenderer.updateCarSpeed(location.speed)
}
if (routeModel.navState.carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
surfaceRenderer.updateCarSpeed(location.speed)
}
updateBearing(location)
if (routeModel.isNavigating()) {
handleNavigationLocation(location)
@@ -410,7 +420,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
navigationManager.navigationStarted()
if (autoDriveEnabled) {
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
routeModel, lifecycle.coroutineScope
) { location ->
updateLocation(location)
}
@@ -418,7 +428,6 @@ class NavigationSession : Session(), NavigationScreen.Listener {
}
override fun updateTrip(trip: Trip) {
Log.d("Trip", trip.toString())
navigationManager.updateTrip(trip)
}
@@ -429,10 +438,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
private fun handleGuidanceAudio() {
val currentStep = routeModel.route.currentStep()
val stepData = routeModel.currentStep()
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < 50) {
if (textToSpeechManager.initialized) {
textToSpeechManager.speak(stepData.message)
}
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) {
textToSpeechManager.speak(stepData.message)
lastStepIndex = currentStep.index
}
}

View File

@@ -70,7 +70,7 @@ class SurfaceRenderer(
// Current camera position state for the map
private val cameraPosition = MutableLiveData(
CameraPosition(
zoom = 15.0,
zoom = 16.0,
target = Position(latitude = homeVogelhart.latitude, longitude = homeVogelhart.longitude)
)
)

View File

@@ -1,61 +1,81 @@
package com.kouros.navigation.car
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.car.app.CarContext
import java.util.Locale
class TextToSpeechManager(private val carContext: CarContext) {
class TextToSpeechManager(private val carContext: Context) {
var textToSpeech: TextToSpeech
private var textToSpeech: TextToSpeech? = null
@Volatile private var initialized = false
var initialized = false
private val audioManager: AudioManager by lazy {
carContext.getSystemService(AudioManager::class.java)!!
}
private val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.build()
private val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(audioAttributes)
.setOnAudioFocusChangeListener { /* Handle focus changes if needed */ }
.build()
init {
textToSpeech = TextToSpeech(carContext) { status ->
if (status == TextToSpeech.SUCCESS) {
Log.d("TTS", "Initialization Success")
val audioAttributes =
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.build()
val request =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(audioAttributes)
.build()
val audioManager: AudioManager =
carContext.getSystemService<AudioManager?>(AudioManager::class.java)!!
// Requesting the audio focus.
if (audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
textToSpeech.setAudioAttributes(audioAttributes)
textToSpeech?.apply {
setAudioAttributes(audioAttributes)
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
// Release focus ONLY after speech is finished
audioManager.abandonAudioFocusRequest(focusRequest)
}
override fun onError(utteranceId: String) {
audioManager.abandonAudioFocusRequest(focusRequest)
}
})
}
initialized = true
Log.d("TTS", "Initialization Success")
} else {
Log.d("TTS", "Initialization Failed")
Log.e("TTS", "Initialization Failed")
}
}
}
fun speak(text: String) {
try {
val cs: CharSequence = text
textToSpeech.speak(cs, TextToSpeech.QUEUE_FLUSH, null, "1233455")
} catch (e: Throwable) {
Log.d("TTS", "speak error", e)
if (!initialized) {
Log.w("TTS", "Ignore speak: Not initialized yet")
return
}
// 1. Request focus
val result = audioManager.requestAudioFocus(focusRequest)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
// 2. Speak with a unique ID to trigger the listener
val utteranceId = System.currentTimeMillis().toString()
textToSpeech?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
}
}
/**
* Cleans up manager.
* Should be called when the session is destroyed.
*/
fun cleanup() {
if (initialized) {
textToSpeech.shutdown()
textToSpeech?.stop()
textToSpeech?.shutdown()
initialized = false
}
}
}

View File

@@ -14,7 +14,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.res.painterResource
@@ -22,6 +24,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kouros.data.R
@@ -210,10 +213,10 @@ fun RouteLayerPoint(routeData: String?) {
fun trafficColor(key: String): Expression<ColorValue> {
when (key) {
"queuing" -> return const(Color(0xFFC46E53))
"stationary" -> return const(Color(0xFFFF0000))
"slow" -> return const(Color(0xFFC43E3E))
"stationary" -> return const(Color(0xFF910A0A))
"heavy" -> return const(Color(0xFF6B0404))
"slow" -> return const(Color(0xFFBD2525))
"roadworks" -> return const(Color(0xFF725A0F))
"roadworks" -> return const(Color(0xFF443506))
}
return const(Color.Blue)
}
@@ -222,10 +225,10 @@ fun trafficColor(key: String): Expression<ColorValue> {
fun AmenityLayer(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
var color = const(Color.Red)
var img = image(painterResource(R.drawable.local_pharmacy_48px), drawAsSdf = true)
var img = image(painterResource(R.drawable.local_pharmacy_24px), drawAsSdf = true)
if (routeData.contains(Constants.CHARGING_STATION)) {
color = const(Color(0xFF054603))
img = image(painterResource(R.drawable.ev_station_48px), drawAsSdf = true)
img = image(painterResource(R.drawable.ev_station_24px), drawAsSdf = true)
} else if (routeData.contains(Constants.FUEL_STATION)) {
color = const(Color.Blue)
img = image(painterResource(R.drawable.local_gas_station_24), drawAsSdf = true)
@@ -321,10 +324,11 @@ fun NavigationImage(
val street = streetName.toString()
val styleStreet = TextStyle(
fontSize = 14.sp,
color = if (darkMode == 1) Color.Yellow else Color.Red,
fontWeight = FontWeight.Bold,
color = if (darkMode == 1) Color.White else navigationColor,
)
val textLayoutStreet = remember(street) {
textMeasurerStreet.measure(street, styleStreet)
textMeasurerStreet.measure(street, styleStreet, overflow = TextOverflow.Ellipsis)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) {
@@ -346,16 +350,26 @@ fun NavigationImage(
)
Canvas(
modifier = Modifier
.size(imageSize.dp + 100.dp, imageSize.dp + 80.dp)
.size((width / 5).dp, (height/4).dp)
) {
if (!streetName.isNullOrEmpty()) {
drawRoundRect(
topLeft = Offset(
x = center.x - textLayoutStreet.size.width / 2 ,
y = center.y + textLayoutStreet.size.height,
),
color = if (darkMode == 1) navigationColor else Color.White,
cornerRadius = CornerRadius(x = 10f, y = 10f),
)
drawText(
textMeasurer = textMeasurerStreet,
text = streetName,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = styleStreet,
topLeft = Offset(
x = center.x - textLayoutStreet.size.width / 2,
y = center.y + textLayoutStreet.size.height,
y = center.y + textLayoutStreet.size.height + 10,
)
)
}

View File

@@ -1,5 +1,8 @@
package com.kouros.navigation.car.navigation
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -16,7 +19,12 @@ import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.Row
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
import com.kouros.navigation.data.Constants.TAG
import java.io.IOException
import java.util.Locale
@@ -66,4 +74,31 @@ class NavigationUtils(private var carContext: CarContext) {
)
.build()
}
fun createNumberIcon(category: String, number: String): IconCompat {
val size = 24
val bitmap = createBitmap(size, size)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = size * 0.7f
textAlign = Paint.Align.CENTER
isFakeBoldText = true
}
val xPos = size / 2f
val yPos = (size / 2f) - ((paint.descent() + paint.ascent()) / 2f)
val color = when (category) {
CHARGING_STATION -> Color.GREEN
FUEL_STATION -> Color.BLUE
PHARMACY -> Color.RED
else -> Color.WHITE
}
paint.color = color
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
paint.color = Color.WHITE
canvas.drawText(number, xPos, yPos, paint)
return IconCompat.createWithBitmap(bitmap)
}
}

View File

@@ -37,7 +37,7 @@ class Simulation {
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 2 seconds)
delay(500)
delay(1000)
lastLocation = fakeLocation
}
routeModel.stopNavigation()

View File

@@ -18,19 +18,32 @@ import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.car.screen.observers.CategoryObserver
import com.kouros.navigation.car.screen.observers.CategoryObserverCallback
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.utils.GeoUtils.createPointCollection
import com.kouros.navigation.utils.location
class CategoriesScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
private val navigationViewModel: NavigationViewModel,
) : Screen(carContext) {
) : Screen(carContext), CategoryObserverCallback {
private val categoryObserver = CategoryObserver(this)
private var category = ""
var categories: List<Category> = listOf(
Category(id = FUEL_STATION, name = carContext.getString(R.string.fuel_station)),
Category(id = PHARMACY, name = carContext.getString(R.string.pharmacy)),
Category(id = CHARGING_STATION, name = carContext.getString(R.string.charging_station))
)
init {
navigationViewModel.elements.observe(this, categoryObserver)
}
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage("No categories to show")
@@ -38,22 +51,10 @@ class CategoriesScreen(
itemListBuilder.addItem(
Row.Builder()
.setTitle(it.name)
.setImage(carIcon(carContext,it.id))
.setImage(carIcon(carContext,it.id, -1))
.setOnClickListener {
screenManager
.pushForResult(
CategoryScreen(
carContext,
surfaceRenderer,
it.id,
navigationViewModel
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
category = it.id
navigationViewModel.getAmenities(it.id, surfaceRenderer.lastLocation)
}
.setBrowsable(true)
.build()
@@ -72,20 +73,49 @@ 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, id: String): CarIcon {
val resId = when (id) {
FUEL_STATION -> R.drawable.local_gas_station_24
PHARMACY -> R.drawable.local_pharmacy_48px
CHARGING_STATION -> R.drawable.ev_station_48px
else -> {}
fun carIcon(context: CarContext, category: String, index: Int): CarIcon {
if (index == -1) {
val resId = when (category) {
CHARGING_STATION -> R.drawable.ev_station_24px
FUEL_STATION -> R.drawable.local_gas_station_24
PHARMACY -> R.drawable.local_pharmacy_24px
else -> R.drawable.ic_place_white_24dp
}
return CarIcon.Builder(IconCompat.createWithResource(context, resId)).build()
} else {
return CarIcon.Builder(NavigationUtils(context).createNumberIcon(category, index.toString())).build()
}
return CarIcon.Builder(
IconCompat.createWithResource(
context,
resId as Int
)
)
.build()
}
}

View File

@@ -14,11 +14,16 @@ import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.lifecycle.Observer
import androidx.car.app.versioning.CarAppApiLevels
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.car.screen.observers.CategoryObserver
import com.kouros.navigation.car.screen.observers.CategoryObserverCallback
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel
@@ -32,57 +37,39 @@ class CategoryScreen(
private val surfaceRenderer: SurfaceRenderer,
private val category: String,
private val navigationViewModel: NavigationViewModel,
private var elements: List<Elements>,
) : Screen(carContext) {
var elements = listOf<Elements>()
val observer = Observer<List<Elements>> { newElements ->
elements = newElements
val coordinates = mutableListOf<List<Double>>()
val loc = location(0.0, 0.0)
elements.forEach {
if (loc.latitude == 0.0) {
loc.longitude = it.lon
loc.latitude = it.lat
}
coordinates.add(listOf(it.lon, it.lat))
}
if (elements.isNotEmpty()) {
val route = createPointCollection(coordinates, category)
surfaceRenderer.setCategories(loc, route)
invalidate()
}
}
init {
navigationViewModel.elements.observe(this, observer)
navigationViewModel.getAmenities(category, surfaceRenderer.lastLocation)
}
val maxListItems: Int = 30
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
var index = 0
val listLimit = min(
50,
carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(
ConstraintManager.CONTENT_LIMIT_TYPE_LIST
)
)
elements.forEach {
if (index++ < listLimit) {
if (it.tags.operator != null) {
listBuilder.addItem(
createItem(it, category)
// Some hosts may allow more items in the list than others, so create more.
if (carContext.getCarAppApiLevel() > CarAppApiLevels.LEVEL_1) {
val listLimit = min(
maxListItems,
carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(
ConstraintManager.CONTENT_LIMIT_TYPE_LIST
)
)
elements.forEach {
if (it.tags.operator != null) {
if (index++ < listLimit) {
listBuilder.addItem(
createItem(it, category, index)
)
}
}
}
}
val header = Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(carContext.getString(R.string.charging_station))
.setTitle(getTitle(carContext, category))
.build()
val builder = MapWithContentTemplate.Builder()
.setContentTemplate(
@@ -99,7 +86,17 @@ class CategoryScreen(
return builder.build()
}
private fun createItem(it: Elements, category: String): Row {
private fun getTitle(carContext: CarContext, category: String): String {
val resId = when (category) {
CHARGING_STATION -> R.string.charging_station
FUEL_STATION -> R.string.fuel_station
PHARMACY -> R.string.pharmacy
else -> R.string.no_places
}
return carContext.getString(resId)
}
private fun createItem(it: Elements, category: String, index: Int): Row {
var name = ""
if (it.tags.name != null) {
name = it.tags.name.toString()
@@ -113,7 +110,7 @@ class CategoryScreen(
surfaceRenderer.setCategoryLocation(location, category)
}
.setTitle(name)
.setImage(carIcon(carContext, category))
.setImage(carIcon(carContext, category, index))
if (it.distance < 1000) {
row.addText("${(it.distance).toInt()} m")
} else {
@@ -127,28 +124,29 @@ class CategoryScreen(
val navigationUtils = NavigationUtils(carContext)
row.addAction(
Action.Builder()
.setOnClickListener {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
location(it.lon!!, it.lat!!),
surfaceRenderer.carOrientation
)
setResult(
Place(
name = name,
category = Constants.CHARGING_STATION,
latitude = it.lat!!,
longitude = it.lon!!
.setOnClickListener {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
location(it.lon, it.lat),
surfaceRenderer.carOrientation
)
)
finish()
}
.setIcon(navigationUtils.createCarIcon(R.drawable.navigation_48px))
.build())
setResult(
Place(
name = name,
category = Constants.CHARGING_STATION,
latitude = it.lat,
longitude = it.lon
)
)
finish()
}
.setIcon(navigationUtils.createCarIcon(R.drawable.navigation_48px))
.build())
return row.build()
}
private fun carText(sText: String): CarText {
val secondText =
CarText.Builder(

View File

@@ -40,8 +40,6 @@ 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.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.getSettingsRepository
@@ -74,7 +72,7 @@ class NavigationScreen(
/** Starts navigation. */
fun startNavigation()
/** Updates trip information. */
fun updateTrip(trip: Trip)
}
@@ -102,8 +100,10 @@ class NavigationScreen(
lifecycleScope.launch {
settingsViewModel.routingEngine.first()
settingsViewModel.recentPlaces.first()
distanceMode = repository.distanceModeFlow.first()
}
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
/**
@@ -112,7 +112,7 @@ class NavigationScreen(
*/
override fun onRouteReceived(route: String) {
if (route.isNotEmpty()) {
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route)
@@ -187,9 +187,7 @@ class NavigationScreen(
* Returns the appropriate template based on the current navigation state.
*/
override fun onGetTemplate(): Template {
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
val actionStripBuilder = createActionStripBuilder()
return when (navigationType) {
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
@@ -330,8 +328,9 @@ class NavigationScreen(
* Builds and returns RoutingInfo based on the current step and distance.
*/
fun getRoutingInfo(): RoutingInfo {
val distance = formattedDistance(distanceMode, routeModel.routeCalculator.leftStepDistance())
val routingInfo = RoutingInfo.Builder()
val distance =
formattedDistance(distanceMode, routeModel.routeCalculator.leftStepDistance())
val routingInfo = RoutingInfo.Builder()
.setCurrentStep(
routeModel.currentStep(carContext = carContext),
Distance.create(distance.first, distance.second)
@@ -460,7 +459,7 @@ class NavigationScreen(
* Creates an action to start the settings screen.
*/
private fun settingsAction(): Action {
return Action.Builder()
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.settings_48px))
.setOnClickListener {
screenManager.push(SettingsScreen(carContext, navigationViewModel))
@@ -628,7 +627,7 @@ class NavigationScreen(
}
updateSpeedCamera(location)
with(routeModel) {
updateLocation( location, navigationViewModel)
updateLocation(location, navigationViewModel)
checkArrival()
}
invalidate()
@@ -658,14 +657,19 @@ class NavigationScreen(
* This includes destination name, address, travel estimate, and loading status.
*/
private fun updateTrip() {
val tripBuilder = Trip.Builder()
val destination = Destination.Builder()
.setName(routeModel.navState.destination.name ?: "")
.setAddress(routeModel.navState.destination.street ?: "")
.build()
tripBuilder.addDestination(destination, routeModel.travelEstimate(carContext, distanceMode))
tripBuilder.setLoading(false)
listener.updateTrip(tripBuilder.build())
if (routeModel.isNavigating()) {
val tripBuilder = Trip.Builder()
val destination = Destination.Builder()
.setName(routeModel.navState.destination.name ?: "")
.setAddress(routeModel.navState.destination.street ?: "")
.build()
tripBuilder.addDestination(
destination,
routeModel.travelEstimate(carContext, distanceMode)
)
tripBuilder.setLoading(false)
listener.updateTrip(tripBuilder.build())
}
}
/**

View File

@@ -142,22 +142,15 @@ class SearchScreen(
@SuppressLint("DefaultLocale")
fun doSearch(searchItemListBuilder: ItemList.Builder) {
if (searchResult.size == 1) {
navigateToPlace(searchResult.first())
}
searchResult.forEach {
searchItemListBuilder.addItem(
Row.Builder()
.setTitle("${(it.distance/1000).toInt()} km ${it.displayName} ")
.setOnClickListener {
val place = Place(
name = it.displayName,
latitude = it.lat.toDouble(),
longitude = it.lon.toDouble(),
street = it.address.road,
city = it.address.city,
postalCode = it.address.postcode,
distance = it.distance
)
setResult(place)
finish()
navigateToPlace(it)
}
.setBrowsable(false)
.build()
@@ -165,4 +158,18 @@ class SearchScreen(
}
invalidate()
}
private fun navigateToPlace(result: SearchResult) {
val place = Place(
name = result.displayName,
latitude = result.lat.toDouble(),
longitude = result.lon.toDouble(),
street = result.address.road,
city = result.address.city,
postalCode = result.address.postcode,
distance = result.distance
)
setResult(place)
finish()
}
}

View File

@@ -0,0 +1,54 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
import com.kouros.navigation.data.overpass.Elements
/**
* Callback interface for category element updates in CategoryScreen.
*/
interface CategoryObserverCallback {
/**
* Called when category elements are ready to display.
* @param elements the full list of elements
* @param centerLat latitude of the first element (used to center the map)
* @param centerLon longitude of the first element (used to center the map)
* @param coordinates all element coordinates as [lon, lat] pairs
*/
fun onCategoryElementsReady(
elements: List<Elements>,
centerLat: Double,
centerLon: Double,
coordinates: List<List<Double>>
)
/** Called to request UI invalidation after elements are updated */
fun invalidateScreen()
}
/**
* Observer for POI/amenity element lists. Extracts coordinates and notifies the screen
* when new data is ready to display.
*/
class CategoryObserver(
private val callback: CategoryObserverCallback
) : Observer<List<Elements>> {
override fun onChanged(value: List<Elements>) {
if (value.isEmpty()) return
var centerLat = 0.0
var centerLon = 0.0
val coordinates = mutableListOf<List<Double>>()
value.forEach { element ->
if (centerLat == 0.0) {
centerLat = element.lat
centerLon = element.lon
}
coordinates.add(listOf(element.lon, element.lat))
}
callback.onCategoryElementsReady(value, centerLat, centerLon, coordinates)
callback.invalidateScreen()
}
}

View File

@@ -83,5 +83,4 @@ class AudioSettings(
private fun onSelected(index: Int) {
settingsViewModel.onGuidanceAudioChanged(index)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.screen.settings.DistanceSettings
@@ -20,16 +21,19 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
private var buildingToggleState = false
private var showTraffic = false
val settingsViewModel = getSettingsViewModel(carContext)
init {
lifecycleScope.launch {
settingsViewModel.show3D.first()
}
}
override fun onGetTemplate(): Template {
buildingToggleState = settingsViewModel.show3D.value
settingsViewModel.traffic.asLiveData().observe(this) {
showTraffic = it
invalidate()
}
settingsViewModel.show3D.asLiveData().observe(this) {
buildingToggleState = it
invalidate()
}
val listBuilder = ItemList.Builder()
val buildingToggle: Toggle =
Toggle.Builder { checked: Boolean ->
@@ -37,6 +41,14 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
buildingToggleState = !buildingToggleState
}.setChecked(buildingToggleState).build()
listBuilder.addItem(buildRowForTemplate(R.string.threed_building, buildingToggle))
val trafficToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onTraffic(checked)
showTraffic = !showTraffic
}.setChecked(showTraffic).build()
listBuilder.addItem(buildRowForTemplate(R.string.traffic, trafficToggle))
listBuilder.addItem(
buildRowForScreenTemplate(
DarkModeSettings(carContext),

View File

@@ -34,7 +34,7 @@ class DistanceSettings(private val carContext: CarContext) : Screen(carContext)
ItemList.Builder()
.addItem(
buildRowForTemplate(
R.string.automaticaly,
R.string.automatically,
)
)
.addItem(

View File

@@ -28,6 +28,9 @@ class NavigationSettings(
private var tollWayToggleState = false
private var ferryToggleState = false
private var carLocationToggleState = false
val settingsViewModel = getSettingsViewModel(carContext)
@@ -36,6 +39,7 @@ class NavigationSettings(
lifecycleScope.launch {
settingsViewModel.avoidTollway.first()
settingsViewModel.avoidMotorway.first()
settingsViewModel.avoidFerry.first()
settingsViewModel.carLocation.first()
}
}
@@ -43,9 +47,12 @@ class NavigationSettings(
override fun onGetTemplate(): Template {
motorWayToggleState = settingsViewModel.avoidMotorway.value
tollWayToggleState = settingsViewModel.avoidTollway.value
ferryToggleState = settingsViewModel.avoidFerry.value
carLocationToggleState = settingsViewModel.carLocation.value
val listBuilder = ItemList.Builder()
// Motorway
val highwayToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onAvoidMotorway(checked)
@@ -66,6 +73,15 @@ class NavigationSettings(
}.setChecked(tollWayToggleState).build()
listBuilder.addItem(buildRowForTemplate(R.string.avoid_tolls_row_title, tollwayToggle))
// Ferry
val ferryToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onAvoidFerry(checked)
ferryToggleState = !ferryToggleState
}.setChecked(ferryToggleState).build()
listBuilder.addItem(buildRowForTemplate(R.string.avoid_ferries, ferryToggle))
// CarLocation
val carLocationToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onCarLocation(checked)

View File

@@ -3,13 +3,23 @@ package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** A screen demonstrating selectable lists. */
class SettingsScreen(
@@ -17,20 +27,66 @@ class SettingsScreen(
private var navigationViewModel: NavigationViewModel,
) : Screen(carContext) {
val settingsViewModel = getSettingsViewModel(carContext)
private var audioToggleState = false
init {
lifecycleScope.launch {
settingsViewModel.guidanceAudio.first()
}
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
listBuilder.addItem(
buildRowForTemplate(
AudioSettings(carContext),
R.string.audio_settings
)
)
settingsViewModel.guidanceAudio.asLiveData().observe(this, Observer {
audioToggleState = settingsViewModel.guidanceAudio.value == 1
invalidate()
})
val templateBuilder = ListTemplate.Builder()
val audioToggle: Toggle =
Toggle.Builder { checked: Boolean ->
if (checked) {
settingsViewModel.onGuidanceAudioChanged(1)
} else {
settingsViewModel.onGuidanceAudioChanged(0)
}
audioToggleState = !audioToggleState
}.setChecked(audioToggleState).build()
var listBuilder = ItemList.Builder()
listBuilder.addItem(Row.Builder()
.setTitle(getTitle())
.setImage(getImage())
.setToggle(audioToggle)
.build())
listBuilder.addItem(
buildRowForTemplate(
DisplaySettings(carContext),
R.string.display
)
)
listBuilder.addItem(
buildRowForTemplate(
AudioSettings(carContext),
R.string.audio_settings
)
)
templateBuilder.addSectionedList(
SectionedItemList.create(
listBuilder.build(),
carContext.getString(R.string.general)
)
)
// Navigation --------------
listBuilder = ItemList.Builder()
listBuilder.addItem(
buildRowForTemplate(
NavigationSettings(carContext, navigationViewModel),
@@ -38,19 +94,41 @@ class SettingsScreen(
)
)
return ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(
Header.Builder()
.setTitle(
(carContext.getString(R.string.settings_action_title))
)
.setStartHeaderAction(Action.BACK)
.build()
templateBuilder.addSectionedList(
SectionedItemList.create(
listBuilder.build(),
carContext.getString(R.string.navigation_settings)
)
)
return templateBuilder
.setHeader( Header.Builder()
.setTitle(
(carContext.getString(R.string.settings_action_title))
)
.setStartHeaderAction(Action.BACK)
.build())
.build()
}
private fun getTitle(): String {
return if (audioToggleState) {
carContext.getString(R.string.on_action_title)
} else {
carContext.getString(R.string.off_action_title)
}
}
private fun getImage(): CarIcon {
return if (audioToggleState) {
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.volume_up_24px))
.build()
} else {
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.volume_off_24px))
.build()
}
}
private fun buildRowForTemplate(screen: Screen, title: Int): Row {
return Row.Builder()
.setTitle(carContext.getString(title))

View File

@@ -0,0 +1,103 @@
package com.kouros.navigation.car.screen.observers
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Tags
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.*
class CategoryObserverTest {
private lateinit var mockCallback: CategoryObserverCallback
private lateinit var observer: CategoryObserver
@Before
fun setup() {
mockCallback = mock()
observer = CategoryObserver(mockCallback)
}
@Test
fun `onChanged with empty list does not invoke callback`() {
observer.onChanged(emptyList())
verify(mockCallback, never()).onCategoryElementsReady(any(), any(), any(), any())
verify(mockCallback, never()).invalidateScreen()
}
@Test
fun `onChanged with single element invokes both callbacks`() {
val element = createElement(lon = 10.5, lat = 52.3)
observer.onChanged(listOf(element))
verify(mockCallback).onCategoryElementsReady(any(), any(), any(), any())
verify(mockCallback).invalidateScreen()
}
@Test
fun `onChanged uses first element coordinates as map center`() {
val first = createElement(lon = 10.5, lat = 52.3)
val second = createElement(lon = 11.0, lat = 53.0)
observer.onChanged(listOf(first, second))
argumentCaptor<Double>().apply {
verify(mockCallback).onCategoryElementsReady(any(), capture(), capture(), any())
val capturedLat = firstValue
val capturedLon = secondValue
assert(capturedLat == 52.3) { "Expected centerLat=52.3 but was $capturedLat" }
assert(capturedLon == 10.5) { "Expected centerLon=10.5 but was $capturedLon" }
}
}
@Test
fun `onChanged passes all elements to callback`() {
val elements = listOf(
createElement(lon = 10.0, lat = 52.0),
createElement(lon = 11.0, lat = 53.0),
createElement(lon = 12.0, lat = 54.0)
)
observer.onChanged(elements)
argumentCaptor<List<Elements>>().apply {
verify(mockCallback).onCategoryElementsReady(capture(), any(), any(), any())
assert(firstValue.size == 3)
}
}
@Test
fun `onChanged builds coordinates list with lon first then lat`() {
val element = createElement(lon = 10.5, lat = 52.3)
observer.onChanged(listOf(element))
argumentCaptor<List<List<Double>>>().apply {
verify(mockCallback).onCategoryElementsReady(any(), any(), any(), capture())
val coords = firstValue
assert(coords.size == 1)
assert(coords[0][0] == 10.5) { "Expected lon=10.5 at index 0 but was ${coords[0][0]}" }
assert(coords[0][1] == 52.3) { "Expected lat=52.3 at index 1 but was ${coords[0][1]}" }
}
}
@Test
fun `onChanged collects coordinates for all elements`() {
val elements = listOf(
createElement(lon = 10.0, lat = 52.0),
createElement(lon = 11.0, lat = 53.0)
)
observer.onChanged(elements)
argumentCaptor<List<List<Double>>>().apply {
verify(mockCallback).onCategoryElementsReady(any(), any(), any(), capture())
assert(firstValue.size == 2)
}
}
private fun createElement(lon: Double, lat: Double): Elements {
return Elements(lon = lon, lat = lat, tags = Tags())
}
}