Distance settings
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.kouros.navigation"
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 57
|
||||
versionName = "0.2.0.57"
|
||||
versionCode = 59
|
||||
versionName = "0.2.0.59"
|
||||
base.archivesName = "navi-$versionName"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -83,8 +83,8 @@ dependencies {
|
||||
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
implementation(project(":common:data"))
|
||||
implementation(project(":common:car"))
|
||||
implementation(project(":common:data"))
|
||||
implementation(libs.play.services.location)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
@@ -20,12 +20,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kouros.data.R
|
||||
import com.kouros.navigation.data.StepData
|
||||
import com.kouros.navigation.utils.formattedDistance
|
||||
import com.kouros.navigation.utils.round
|
||||
|
||||
private const val MANEUVER_TYPE_EXIT_RIGHT = 45
|
||||
private const val MANEUVER_TYPE_EXIT_LEFT = 46
|
||||
private const val METERS_PER_KILOMETER = 1000.0
|
||||
private const val DISTANCE_THRESHOLD_METERS = 1000
|
||||
private const val DISTANCE_THRESHOLD = 1000
|
||||
|
||||
private val CardTopPadding = 60.dp
|
||||
private val CardElevation = 6.dp
|
||||
@@ -92,8 +93,9 @@ fun NavigationInfo(
|
||||
|
||||
@Composable
|
||||
private fun DistanceText(distance: Double) {
|
||||
val distancexx = formattedDistance(2, distance)
|
||||
val formattedDistance = when {
|
||||
distance < DISTANCE_THRESHOLD_METERS -> "${distance.toInt()} m"
|
||||
distance < DISTANCE_THRESHOLD -> "${distance.toInt()} m"
|
||||
else -> "${(distance / METERS_PER_KILOMETER).round(1)} km"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.kouros.navigation
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.kouros.navigation", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.kouros.navigation
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class RouteModelTest {
|
||||
} else {
|
||||
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
|
||||
}
|
||||
assertEquals(stepData.leftStepDistance, 300.0, 1.0)
|
||||
assertEquals(stepData.leftStepDistance, 301.0, 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -192,7 +192,7 @@ class RouteModelTest {
|
||||
val location: Location = location( 11.584578, 48.183653)
|
||||
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()) )
|
||||
val step = routeModel.currentStep()
|
||||
assertEquals(step.leftStepDistance, 650.0, 0.1)
|
||||
assertEquals(step.leftStepDistance, 645.0, 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -200,6 +200,6 @@ class RouteModelTest {
|
||||
val location: Location = location( 11.578911, 48.185565)
|
||||
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()) )
|
||||
val step = routeModel.currentStep()
|
||||
assertEquals(step.leftStepDistance , 30.0, 1.0)
|
||||
assertEquals(step.leftStepDistance , 34.0, 1.0)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class DeviceLocationManager(
|
||||
* Only processes location if car location hardware is not being used.
|
||||
*/
|
||||
private val locationListener: LocationListenerCompat = LocationListenerCompat { location ->
|
||||
if (location != null && shouldUseDeviceLocation) {
|
||||
if (shouldUseDeviceLocation) {
|
||||
onLocationUpdate(location)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.kouros.navigation.car
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.Manifest.permission
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
@@ -11,9 +10,10 @@ import androidx.car.app.Screen
|
||||
import androidx.car.app.ScreenManager
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.connection.CarConnection
|
||||
import androidx.core.net.toUri
|
||||
import androidx.car.app.navigation.NavigationManager
|
||||
import androidx.car.app.navigation.NavigationManagerCallback
|
||||
import androidx.car.app.navigation.model.Trip
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
@@ -23,6 +23,8 @@ import com.kouros.navigation.car.navigation.RouteCarModel
|
||||
import com.kouros.navigation.car.screen.NavigationScreen
|
||||
import com.kouros.navigation.car.screen.RequestPermissionScreen
|
||||
import com.kouros.navigation.car.screen.SearchScreen
|
||||
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
|
||||
import com.kouros.navigation.data.Constants.GMS_CAR_SPEED_PERMISSION
|
||||
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
|
||||
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
|
||||
import com.kouros.navigation.data.Constants.TAG
|
||||
@@ -35,9 +37,7 @@ import com.kouros.navigation.utils.GeoUtils.snapLocation
|
||||
import com.kouros.navigation.utils.NavigationUtils.getViewModel
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.launch
|
||||
import android.Manifest.permission
|
||||
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
|
||||
import com.kouros.navigation.data.Constants.GMS_CAR_SPEED_PERMISSION
|
||||
|
||||
|
||||
/**
|
||||
* Main session for Android Auto/Automotive OS navigation.
|
||||
@@ -65,12 +65,17 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
// Manages device GPS location updates
|
||||
lateinit var deviceLocationManager: DeviceLocationManager
|
||||
|
||||
lateinit var navigationManager: NavigationManager
|
||||
|
||||
/**
|
||||
* Lifecycle observer for managing session lifecycle events.
|
||||
* Cleans up resources when the session is destroyed.
|
||||
*/
|
||||
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
if (::navigationManager.isInitialized) {
|
||||
navigationManager.clearNavigationManagerCallback()
|
||||
}
|
||||
if (::carSensorManager.isInitialized) {
|
||||
carSensorManager.cleanup()
|
||||
}
|
||||
@@ -178,6 +183,21 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
* Initializes managers for rendering, sensors, and location.
|
||||
*/
|
||||
private fun initializeManagers() {
|
||||
|
||||
navigationManager = carContext.getCarService(NavigationManager::class.java)
|
||||
navigationManager.setNavigationManagerCallback(object : NavigationManagerCallback {
|
||||
override fun onAutoDriveEnabled() {
|
||||
// Called when the app should simulate navigation (e.g., for testing)
|
||||
// Implement your simulation logic here
|
||||
Log.d("CarApp", "Auto Drive Enabled")
|
||||
}
|
||||
|
||||
override fun onStopNavigation() {
|
||||
// Called when the user stops navigation in the car screen
|
||||
Log.d("CarApp", "Stop Navigation Requested")
|
||||
// Stop turn-by-turn logic and clean up
|
||||
}
|
||||
})
|
||||
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
|
||||
|
||||
carSensorManager = CarSensorManager(
|
||||
@@ -346,6 +366,20 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
*/
|
||||
override fun stopNavigation() {
|
||||
routeModel.stopNavigation()
|
||||
navigationManager.navigationEnded()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start navigation process.
|
||||
* Called when user starts navigation
|
||||
*/
|
||||
override fun startNavigation() {
|
||||
navigationManager.navigationStarted()
|
||||
}
|
||||
|
||||
override fun updateTrip(trip: Trip) {
|
||||
Log.d("Trip", trip.toString())
|
||||
navigationManager.updateTrip(trip)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.kouros.navigation.car.navigation
|
||||
|
||||
import android.location.Location
|
||||
import android.text.SpannableString
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
@@ -26,9 +25,7 @@ import androidx.core.graphics.drawable.IconCompat
|
||||
import com.kouros.data.R
|
||||
import com.kouros.navigation.data.StepData
|
||||
import com.kouros.navigation.model.RouteModel
|
||||
import com.kouros.navigation.utils.location
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import com.kouros.navigation.utils.formattedDistance
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -87,24 +84,21 @@ class RouteCarModel() : RouteModel() {
|
||||
return step
|
||||
}
|
||||
|
||||
fun travelEstimate(carContext: CarContext): TravelEstimate {
|
||||
fun travelEstimate(carContext: CarContext, distanceMode: Int): TravelEstimate {
|
||||
val timeLeft = routeCalculator.travelLeftTime()
|
||||
val timeToDestinationMillis =
|
||||
TimeUnit.SECONDS.toMillis(timeLeft.toLong())
|
||||
val leftDistance = routeCalculator.travelLeftDistance() / 1000
|
||||
val displayUnit = if (leftDistance > 1.0) {
|
||||
Distance.UNIT_KILOMETERS
|
||||
} else {
|
||||
Distance.UNIT_METERS
|
||||
}
|
||||
val distance = formattedDistance(distanceMode, routeCalculator.travelLeftDistance())
|
||||
|
||||
val arrivalTime = DateTimeWithZone.create(
|
||||
routeCalculator.arrivalTime(),
|
||||
TimeZone.getTimeZone("Europe/Berlin")
|
||||
TimeZone.getDefault()
|
||||
)
|
||||
val traffic = (route.routes.first().summary.trafficDelay/60).toInt()
|
||||
val travelBuilder = TravelEstimate.Builder( // The estimated distance to the destination.
|
||||
Distance.create(
|
||||
leftDistance,
|
||||
displayUnit
|
||||
distance.first,
|
||||
distance.second
|
||||
), // Arrival time at the destination with the destination time zone.
|
||||
arrivalTime
|
||||
)
|
||||
@@ -115,7 +109,7 @@ class RouteCarModel() : RouteModel() {
|
||||
)
|
||||
.setRemainingTimeColor(CarColor.GREEN)
|
||||
.setRemainingDistanceColor(CarColor.BLUE)
|
||||
|
||||
.setTripText(CarText.create("$traffic min"))
|
||||
if (navState.travelMessage.isNotEmpty()) {
|
||||
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
|
||||
travelBuilder.setTripText(CarText.create(navState.travelMessage))
|
||||
|
||||
@@ -42,6 +42,12 @@ class DisplaySettings(private val carContext: CarContext) : Screen(carContext) {
|
||||
R.string.dark_mode
|
||||
)
|
||||
)
|
||||
listBuilder.addItem(
|
||||
buildRowForScreenTemplate(
|
||||
DistanceSettings(carContext),
|
||||
R.string.distance_units
|
||||
)
|
||||
)
|
||||
return ListTemplate.Builder()
|
||||
.setSingleList(listBuilder.build())
|
||||
.setHeader(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.kouros.navigation.car.screen
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SectionedItemList
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.kouros.data.R
|
||||
import com.kouros.navigation.utils.getSettingsViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DistanceSettings(private val carContext: CarContext) : Screen(carContext) {
|
||||
|
||||
private var distanceSettings = 0
|
||||
|
||||
val settingsViewModel = getSettingsViewModel(carContext)
|
||||
|
||||
init {
|
||||
lifecycleScope.launch {
|
||||
settingsViewModel.distanceMode.first()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
distanceSettings = settingsViewModel.distanceMode.value
|
||||
val templateBuilder = ListTemplate.Builder()
|
||||
val radioList =
|
||||
ItemList.Builder()
|
||||
.addItem(
|
||||
buildRowForTemplate(
|
||||
R.string.automaticaly,
|
||||
)
|
||||
)
|
||||
.addItem(
|
||||
buildRowForTemplate(
|
||||
R.string.kilometer,
|
||||
)
|
||||
)
|
||||
.addItem(
|
||||
buildRowForTemplate(
|
||||
R.string.miles,
|
||||
)
|
||||
)
|
||||
.setOnSelectedListener { index: Int ->
|
||||
this.onSelected(index)
|
||||
}
|
||||
.setSelectedIndex(distanceSettings)
|
||||
.build()
|
||||
|
||||
return templateBuilder
|
||||
.addSectionedList(
|
||||
SectionedItemList.create(
|
||||
radioList,
|
||||
carContext.getString(R.string.distance_units)
|
||||
)
|
||||
)
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.distance_units))
|
||||
.setStartHeaderAction(Action.BACK)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
private fun onSelected(index: Int) {
|
||||
settingsViewModel.onDistanceModeChanged(index)
|
||||
}
|
||||
|
||||
private fun buildRowForTemplate(title: Int): Row {
|
||||
return Row.Builder()
|
||||
.setTitle(carContext.getString(title))
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.kouros.navigation.car.screen
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
@@ -18,12 +17,16 @@ import androidx.car.app.model.Distance
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.MessageTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.navigation.model.Destination
|
||||
import androidx.car.app.navigation.model.Maneuver
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.car.app.navigation.model.MessageInfo
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Trip
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.kouros.data.R
|
||||
import com.kouros.navigation.car.SurfaceRenderer
|
||||
@@ -31,12 +34,12 @@ import com.kouros.navigation.car.ViewStyle
|
||||
import com.kouros.navigation.car.navigation.RouteCarModel
|
||||
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
|
||||
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
|
||||
import com.kouros.navigation.data.Constants
|
||||
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.overpass.Elements
|
||||
import com.kouros.navigation.model.NavigationViewModel
|
||||
import com.kouros.navigation.utils.GeoUtils
|
||||
import com.kouros.navigation.utils.formattedDistance
|
||||
import com.kouros.navigation.utils.getSettingsRepository
|
||||
import com.kouros.navigation.utils.getSettingsViewModel
|
||||
import com.kouros.navigation.utils.location
|
||||
@@ -48,6 +51,10 @@ import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
/**
|
||||
* Main screen for car navigation.
|
||||
* Handles different navigation states and provides corresponding templates.
|
||||
*/
|
||||
class NavigationScreen(
|
||||
carContext: CarContext,
|
||||
private var surfaceRenderer: SurfaceRenderer,
|
||||
@@ -60,10 +67,15 @@ class NavigationScreen(
|
||||
interface Listener {
|
||||
/** Stops navigation. */
|
||||
fun stopNavigation()
|
||||
|
||||
/** Starts navigation. */
|
||||
fun startNavigation()
|
||||
|
||||
/** Updates trip information. */
|
||||
fun updateTrip(trip: Trip)
|
||||
}
|
||||
|
||||
val backGroundColor = CarColor.BLUE
|
||||
|
||||
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
|
||||
var recentPlace = Place()
|
||||
var navigationType = NavigationType.VIEW
|
||||
@@ -74,18 +86,25 @@ class NavigationScreen(
|
||||
|
||||
val observerManager = NavigationObserverManager(navigationViewModel, this)
|
||||
|
||||
val repository = getSettingsRepository(carContext)
|
||||
|
||||
var distanceMode = 0
|
||||
|
||||
init {
|
||||
observerManager.attachAllObservers(this)
|
||||
lifecycleScope.launch {
|
||||
getSettingsViewModel(carContext).routingEngine.first()
|
||||
getSettingsViewModel(carContext).recentPlaces.first()
|
||||
distanceMode = repository.distanceModeFlow.first()
|
||||
}
|
||||
}
|
||||
|
||||
// NavigationObserverCallback implementations
|
||||
/**
|
||||
* Handles the received route string.
|
||||
* Starts navigation and invalidates the screen.
|
||||
*/
|
||||
override fun onRouteReceived(route: String) {
|
||||
if (route.isNotEmpty()) {
|
||||
val repository = getSettingsRepository(carContext)
|
||||
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
|
||||
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
|
||||
navigationType = NavigationType.NAVIGATION
|
||||
@@ -94,26 +113,45 @@ class NavigationScreen(
|
||||
getSettingsViewModel(carContext).onLastRouteChanged(route)
|
||||
}
|
||||
surfaceRenderer.setRouteData()
|
||||
listener.startNavigation()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if navigation is currently active.
|
||||
*/
|
||||
override fun isNavigating(): Boolean = routeModel.isNavigating()
|
||||
|
||||
/**
|
||||
* Handles the received recent place.
|
||||
* Updates the navigation type to RECENT and invalidates the screen.
|
||||
*/
|
||||
override fun onRecentPlaceReceived(place: Place) {
|
||||
recentPlace = place
|
||||
navigationType = NavigationType.RECENT
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles received traffic data and updates the surface renderer.
|
||||
*/
|
||||
override fun onTrafficReceived(traffic: Map<String, String>) {
|
||||
surfaceRenderer.setTrafficData(traffic)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the received place search result.
|
||||
* Navigates to the specified place.
|
||||
*/
|
||||
override fun onPlaceSearchResultReceived(place: Place) {
|
||||
navigateToPlace(place)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles received speed camera data.
|
||||
* Updates the surface renderer with the camera locations.
|
||||
*/
|
||||
override fun onSpeedCamerasReceived(cameras: List<Elements>) {
|
||||
speedCameras = cameras
|
||||
val coordinates = mutableListOf<List<Double>>()
|
||||
@@ -124,15 +162,27 @@ class NavigationScreen(
|
||||
surfaceRenderer.speedCamerasData.value = speedData
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles received maximum speed data and updates the surface renderer.
|
||||
*/
|
||||
override fun onMaxSpeedReceived(speed: Int) {
|
||||
surfaceRenderer.maxSpeed.value = speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the screen.
|
||||
*/
|
||||
override fun invalidateScreen() {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate template based on the current navigation state.
|
||||
*/
|
||||
override fun onGetTemplate(): Template {
|
||||
repository.distanceModeFlow.asLiveData().observe(this, Observer {
|
||||
distanceMode = it
|
||||
})
|
||||
val actionStripBuilder = createActionStripBuilder()
|
||||
return when (navigationType) {
|
||||
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
|
||||
@@ -143,21 +193,28 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a NavigationTemplate for the active navigation state.
|
||||
*/
|
||||
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
|
||||
actionStripBuilder.addAction(
|
||||
stopAction()
|
||||
)
|
||||
updateTrip()
|
||||
return NavigationTemplate.Builder()
|
||||
.setNavigationInfo(
|
||||
getRoutingInfo()
|
||||
)
|
||||
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext))
|
||||
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext, distanceMode))
|
||||
.setActionStrip(actionStripBuilder.build())
|
||||
.setMapActionStrip(mapActionStripBuilder().build())
|
||||
.setBackgroundColor(backGroundColor)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a template for the default view state.
|
||||
*/
|
||||
private fun navigationViewTemplate(actionStripBuilder: ActionStrip.Builder): Template {
|
||||
return NavigationTemplate.Builder()
|
||||
.setBackgroundColor(CarColor.SECONDARY)
|
||||
@@ -167,6 +224,9 @@ class NavigationScreen(
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a template for the arrival or end state of navigation.
|
||||
*/
|
||||
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
|
||||
if (routeModel.navState.arrived) {
|
||||
val timer = object : CountDownTimer(8000, 1000) {
|
||||
@@ -189,6 +249,9 @@ class NavigationScreen(
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates and returns a NavigationTemplate specifically for when the destination is reached.
|
||||
*/
|
||||
fun navigationArrivedTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
|
||||
var street = ""
|
||||
if (routeModel.navState.destination.street != null) {
|
||||
@@ -217,6 +280,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a template showing recent places or destinations.
|
||||
*/
|
||||
fun navigationRecentPlaceTemplate(): Template {
|
||||
val messageTemplate = MessageTemplate.Builder(
|
||||
recentPlace.name + "\n"
|
||||
@@ -242,6 +308,9 @@ class NavigationScreen(
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a template for when the route is being recalculated.
|
||||
*/
|
||||
fun navigationRerouteTemplate(actionStripBuilder: ActionStrip.Builder): Template {
|
||||
return NavigationTemplate.Builder()
|
||||
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
|
||||
@@ -250,19 +319,15 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns RoutingInfo based on the current step and distance.
|
||||
*/
|
||||
fun getRoutingInfo(): RoutingInfo {
|
||||
var currentDistance = routeModel.routeCalculator.leftStepDistance()
|
||||
val displayUnit = if (currentDistance > 1000.0) {
|
||||
currentDistance /= 1000.0
|
||||
Distance.UNIT_KILOMETERS
|
||||
} else {
|
||||
Distance.UNIT_METERS
|
||||
}
|
||||
|
||||
val distance = formattedDistance(distanceMode, routeModel.routeCalculator.leftStepDistance())
|
||||
val routingInfo = RoutingInfo.Builder()
|
||||
.setCurrentStep(
|
||||
routeModel.currentStep(carContext = carContext),
|
||||
Distance.create(currentDistance, displayUnit)
|
||||
Distance.create(distance.first, distance.second)
|
||||
)
|
||||
if (routeModel.navState.nextStep) {
|
||||
val nextStep = routeModel.nextStep(carContext = carContext)
|
||||
@@ -271,6 +336,9 @@ class NavigationScreen(
|
||||
return routingInfo.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ActionStrip builder with common search and settings actions.
|
||||
*/
|
||||
private fun createActionStripBuilder(): ActionStrip.Builder {
|
||||
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
|
||||
actionStripBuilder.addAction(
|
||||
@@ -282,6 +350,9 @@ class NavigationScreen(
|
||||
return actionStripBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ActionStrip builder for map-related actions like zoom and pan.
|
||||
*/
|
||||
private fun mapActionStripBuilder(): ActionStrip.Builder {
|
||||
val actionStripBuilder = ActionStrip.Builder()
|
||||
.addAction(zoomPlus())
|
||||
@@ -295,6 +366,9 @@ class NavigationScreen(
|
||||
return actionStripBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stop navigation action.
|
||||
*/
|
||||
private fun stopAction(): Action {
|
||||
return Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.stop_action_title))
|
||||
@@ -313,6 +387,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to start navigation to a specific place.
|
||||
*/
|
||||
private fun navigateAction(): Action {
|
||||
navigationType = NavigationType.NAVIGATION
|
||||
return Action.Builder()
|
||||
@@ -338,6 +415,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to close the current view or template.
|
||||
*/
|
||||
private fun closeAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
@@ -357,6 +437,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to start the search screen.
|
||||
*/
|
||||
private fun searchAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(routeModel.createCarIcon(carContext, R.drawable.search_48px))
|
||||
@@ -366,6 +449,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to start the settings screen.
|
||||
*/
|
||||
private fun settingsAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(routeModel.createCarIcon(carContext, R.drawable.settings_48px))
|
||||
@@ -375,6 +461,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to zoom in on the map.
|
||||
*/
|
||||
private fun zoomPlus(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
@@ -392,6 +481,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to zoom out on the map.
|
||||
*/
|
||||
private fun zoomMinus(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
@@ -409,6 +501,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to enable map panning.
|
||||
*/
|
||||
private fun panAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
@@ -426,6 +521,9 @@ class NavigationScreen(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the search screen and handles the search result.
|
||||
*/
|
||||
private fun startSearchScreen() {
|
||||
screenManager
|
||||
.pushForResult(
|
||||
@@ -450,6 +548,9 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a route to the specified place and sets it as the destination.
|
||||
*/
|
||||
fun navigateToPlace(place: Place) {
|
||||
navigationType = NavigationType.VIEW
|
||||
val location = location(place.longitude, place.latitude)
|
||||
@@ -465,6 +566,9 @@ class NavigationScreen(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops navigation, resets state, and notifies listeners.
|
||||
*/
|
||||
fun stopNavigation() {
|
||||
navigationType = NavigationType.VIEW
|
||||
listener.stopNavigation()
|
||||
@@ -473,6 +577,9 @@ class NavigationScreen(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates recalculation for a new route to the destination.
|
||||
*/
|
||||
fun calculateNewRoute(destination: Place) {
|
||||
stopNavigation()
|
||||
navigationType = NavigationType.REROUTE
|
||||
@@ -489,6 +596,9 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-requests a route for the specified destination.
|
||||
*/
|
||||
fun reRoute(destination: Place) {
|
||||
val dest = location(destination.longitude, destination.latitude)
|
||||
navigationViewModel.loadRoute(
|
||||
@@ -499,6 +609,9 @@ class NavigationScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates navigation state with the current location, checks for arrival, and traffic updates.
|
||||
*/
|
||||
fun updateTrip(location: Location) {
|
||||
val current = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val duration = Duration.between(current, lastTrafficDate)
|
||||
@@ -526,6 +639,24 @@ class NavigationScreen(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the trip information and notifies the listener with a new Trip object.
|
||||
* This includes destination name, address, travel estimate, and loading status.
|
||||
*/
|
||||
private fun updateTrip() {
|
||||
val tripBuilder = Trip.Builder()
|
||||
val destination = Destination.Builder()
|
||||
.setName(routeModel.navState.destination.name ?: "")
|
||||
.setAddress(routeModel.navState.destination.street ?: "")
|
||||
.build()
|
||||
tripBuilder.addDestination(destination, routeModel.travelEstimate(carContext, distanceMode))
|
||||
tripBuilder.setLoading(false)
|
||||
listener.updateTrip(tripBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically requests speed camera information near the current location.
|
||||
*/
|
||||
private fun updateSpeedCamera(location: Location) {
|
||||
if (lastCameraSearch++ % 100 == 0) {
|
||||
navigationViewModel.getSpeedCameras(location, 5.0)
|
||||
@@ -535,6 +666,9 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates distances to nearby speed cameras and checks for proximity alerts.
|
||||
*/
|
||||
private fun updateDistance(
|
||||
location: Location,
|
||||
) {
|
||||
@@ -565,6 +699,9 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a specific permission and updates the view model upon grant.
|
||||
*/
|
||||
fun checkPermission(permission: String) {
|
||||
if (carContext.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
val permissions: MutableList<String?> = ArrayList()
|
||||
@@ -588,6 +725,9 @@ class NavigationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible states for the navigation UI.
|
||||
*/
|
||||
enum class NavigationType {
|
||||
VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.asLiveData
|
||||
import com.kouros.data.R
|
||||
import com.kouros.navigation.car.SurfaceRenderer
|
||||
import com.kouros.navigation.car.navigation.RouteCarModel
|
||||
@@ -25,6 +26,7 @@ import com.kouros.navigation.data.Constants.FAVORITES
|
||||
import com.kouros.navigation.data.Constants.RECENT
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.model.NavigationViewModel
|
||||
import com.kouros.navigation.utils.getSettingsRepository
|
||||
|
||||
|
||||
class PlaceListScreen(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
|
||||
/**
|
||||
* Observer for max speed updates.
|
||||
*/
|
||||
class MaxSpeedObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<Int> {
|
||||
|
||||
override fun onChanged(value: Int) {
|
||||
callback.onMaxSpeedReceived(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.nominatim.SearchResult
|
||||
import com.kouros.navigation.data.overpass.Elements
|
||||
|
||||
/**
|
||||
* Callback interface for observer events that need to be handled by NavigationScreen.
|
||||
*/
|
||||
interface NavigationObserverCallback {
|
||||
/** Called when a route is received and navigation should start */
|
||||
fun onRouteReceived(route: String)
|
||||
|
||||
/** Called when a recent place is selected but navigation hasn't started */
|
||||
fun onRecentPlaceReceived(place: Place)
|
||||
|
||||
/** Check if currently navigating */
|
||||
fun isNavigating(): Boolean
|
||||
|
||||
/** Called when traffic data is updated */
|
||||
fun onTrafficReceived(traffic: Map<String, String>)
|
||||
|
||||
/** Called when a place search result is received */
|
||||
fun onPlaceSearchResultReceived(place: Place)
|
||||
|
||||
/** Called when speed cameras are updated */
|
||||
fun onSpeedCamerasReceived(cameras: List<Elements>)
|
||||
|
||||
/** Called when max speed is updated */
|
||||
fun onMaxSpeedReceived(speed: Int)
|
||||
|
||||
/** Called to request UI invalidation/refresh */
|
||||
fun invalidateScreen()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import com.kouros.navigation.model.NavigationViewModel
|
||||
|
||||
/**
|
||||
* Manager class that handles all NavigationScreen observers.
|
||||
* Centralizes observer creation, registration, and lifecycle management.
|
||||
*/
|
||||
class NavigationObserverManager(
|
||||
private val viewModel: NavigationViewModel,
|
||||
callback: NavigationObserverCallback
|
||||
) {
|
||||
|
||||
val routeObserver = RouteObserver(callback)
|
||||
val recentPlaceObserver = RecentPlaceObserver(callback)
|
||||
val trafficObserver = TrafficObserver(callback)
|
||||
val placeSearchObserver = PlaceSearchObserver(callback)
|
||||
val speedCameraObserver = SpeedCameraObserver(callback)
|
||||
val maxSpeedObserver = MaxSpeedObserver(callback)
|
||||
|
||||
/**
|
||||
* Attaches all observers to the ViewModel.
|
||||
* Call this from NavigationScreen's init block or lifecycle method.
|
||||
*/
|
||||
fun attachAllObservers(screen: androidx.car.app.Screen) {
|
||||
viewModel.route.observe(screen, routeObserver)
|
||||
viewModel.traffic.observe(screen, trafficObserver)
|
||||
viewModel.recentPlace.observe(screen, recentPlaceObserver)
|
||||
viewModel.placeLocation.observe(screen, placeSearchObserver)
|
||||
viewModel.speedCameras.observe(screen, speedCameraObserver)
|
||||
viewModel.maxSpeed.observe(screen, maxSpeedObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches all observers from the ViewModel.
|
||||
* Call this when the screen is being destroyed.
|
||||
*/
|
||||
fun detachAllObservers() {
|
||||
// LiveData observers are automatically removed when the lifecycle is destroyed,
|
||||
// but we can manually remove them if needed for testing or cleanup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
import com.kouros.navigation.data.Constants
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.nominatim.SearchResult
|
||||
|
||||
/**
|
||||
* Observer for place search results. Converts SearchResult to Place and navigates to it.
|
||||
*/
|
||||
class PlaceSearchObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<SearchResult> {
|
||||
|
||||
override fun onChanged(value: SearchResult) {
|
||||
val place = Place(
|
||||
name = value.displayName,
|
||||
street = value.address.road,
|
||||
city = value.address.city,
|
||||
latitude = value.lat.toDouble(),
|
||||
longitude = value.lon.toDouble(),
|
||||
category = Constants.CONTACTS,
|
||||
postalCode = value.address.postcode
|
||||
)
|
||||
callback.onPlaceSearchResultReceived(place)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
import com.kouros.navigation.data.Place
|
||||
|
||||
/**
|
||||
* Observer for recent place updates. Updates the recent place when navigation is not active.
|
||||
*/
|
||||
class RecentPlaceObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<Place> {
|
||||
|
||||
override fun onChanged(value: Place) {
|
||||
if (!callback.isNavigating()) {
|
||||
callback.onRecentPlaceReceived(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
|
||||
/**
|
||||
* Observer for route updates. Triggers navigation start when a non-empty route is received.
|
||||
*/
|
||||
class RouteObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<String> {
|
||||
|
||||
override fun onChanged(value: String) {
|
||||
if (value.isNotEmpty()) {
|
||||
callback.onRouteReceived(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
import com.kouros.navigation.data.overpass.Elements
|
||||
|
||||
/**
|
||||
* Observer for speed camera updates.
|
||||
*/
|
||||
class SpeedCameraObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<List<Elements>> {
|
||||
|
||||
override fun onChanged(value: List<Elements>) {
|
||||
callback.onSpeedCamerasReceived(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import androidx.lifecycle.Observer
|
||||
|
||||
/**
|
||||
* Observer for traffic data updates.
|
||||
*/
|
||||
class TrafficObserver(
|
||||
private val callback: NavigationObserverCallback
|
||||
) : Observer<Map<String, String>> {
|
||||
|
||||
override fun onChanged(value: Map<String, String>) {
|
||||
callback.onTrafficReceived(value)
|
||||
callback.invalidateScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.kouros.navigation.car.screen.observers
|
||||
|
||||
import com.kouros.navigation.data.Constants
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.nominatim.SearchResult
|
||||
import com.kouros.navigation.data.nominatim.Address
|
||||
import com.kouros.navigation.data.overpass.Elements
|
||||
import com.kouros.navigation.data.overpass.Tags
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.*
|
||||
|
||||
/**
|
||||
* Unit tests for NavigationScreen observer classes using Mockito.
|
||||
*/
|
||||
class ObserversTest {
|
||||
|
||||
private lateinit var mockCallback: NavigationObserverCallback
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockCallback = mock()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RouteObserver triggers callback when route is not empty`() {
|
||||
val observer = RouteObserver(mockCallback)
|
||||
val testRoute = "test_route_data"
|
||||
|
||||
observer.onChanged(testRoute)
|
||||
|
||||
verify(mockCallback).onRouteReceived(testRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RouteObserver does not trigger callback when route is empty`() {
|
||||
val observer = RouteObserver(mockCallback)
|
||||
|
||||
observer.onChanged("")
|
||||
|
||||
verify(mockCallback, never()).onRouteReceived(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RecentPlaceObserver triggers callback when navigating is false`() {
|
||||
val observer = RecentPlaceObserver(mockCallback)
|
||||
val testPlace = createTestPlace()
|
||||
whenever(mockCallback.isNavigating()).thenReturn(false)
|
||||
|
||||
observer.onChanged(testPlace)
|
||||
|
||||
verify(mockCallback).isNavigating()
|
||||
verify(mockCallback).onRecentPlaceReceived(testPlace)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RecentPlaceObserver does not trigger callback when navigating is true`() {
|
||||
val observer = RecentPlaceObserver(mockCallback)
|
||||
val testPlace = createTestPlace()
|
||||
whenever(mockCallback.isNavigating()).thenReturn(true)
|
||||
|
||||
observer.onChanged(testPlace)
|
||||
|
||||
verify(mockCallback).isNavigating()
|
||||
verify(mockCallback, never()).onRecentPlaceReceived(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TrafficObserver triggers callback with traffic data and invalidates screen`() {
|
||||
val observer = TrafficObserver(mockCallback)
|
||||
val trafficData = mapOf("road1" to "congestion", "road2" to "clear")
|
||||
|
||||
observer.onChanged(trafficData)
|
||||
|
||||
verify(mockCallback).onTrafficReceived(trafficData)
|
||||
verify(mockCallback).invalidateScreen()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PlaceSearchObserver converts SearchResult to Place and triggers callback`() {
|
||||
val observer = PlaceSearchObserver(mockCallback)
|
||||
val searchResult = createTestSearchResult()
|
||||
|
||||
observer.onChanged(searchResult)
|
||||
|
||||
argumentCaptor<Place>().apply {
|
||||
verify(mockCallback).onPlaceSearchResultReceived(capture())
|
||||
assert(firstValue.name == "Test Place")
|
||||
assert(firstValue.street == "Test Street")
|
||||
assert(firstValue.city == "Test City")
|
||||
assert(firstValue.latitude == 52.0)
|
||||
assert(firstValue.longitude == 10.0)
|
||||
assert(firstValue.category == Constants.CONTACTS)
|
||||
assert(firstValue.postalCode == "12345")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SpeedCameraObserver triggers callback with camera list`() {
|
||||
val observer = SpeedCameraObserver(mockCallback)
|
||||
val cameras = listOf(
|
||||
createTestElements(10.0, 52.0, "50"),
|
||||
createTestElements(10.1, 52.1, "30")
|
||||
)
|
||||
|
||||
observer.onChanged(cameras)
|
||||
|
||||
verify(mockCallback).onSpeedCamerasReceived(cameras)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MaxSpeedObserver triggers callback with speed value`() {
|
||||
val observer = MaxSpeedObserver(mockCallback)
|
||||
val testSpeed = 50
|
||||
|
||||
observer.onChanged(testSpeed)
|
||||
|
||||
verify(mockCallback).onMaxSpeedReceived(testSpeed)
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private fun createTestPlace(): Place {
|
||||
return Place(
|
||||
name = "Test Place",
|
||||
street = "Test Street",
|
||||
city = "Test City",
|
||||
latitude = 52.0,
|
||||
longitude = 10.0,
|
||||
category = Constants.FAVORITES
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestSearchResult(): SearchResult {
|
||||
return SearchResult(
|
||||
displayName = "Test Place",
|
||||
lat = "52.0",
|
||||
lon = "10.0",
|
||||
address = Address(
|
||||
road = "Test Street",
|
||||
city = "Test City",
|
||||
postcode = "12345"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestElements(lon: Double, lat: Double, maxSpeed: String): Elements {
|
||||
return Elements(
|
||||
lon = lon,
|
||||
lat = lat,
|
||||
tags = Tags(maxspeed = maxSpeed, direction = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,4 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.runner)
|
||||
androidTestImplementation(libs.androidx.rules)
|
||||
|
||||
|
||||
}
|
||||
|
||||
1
common/data/proguard-rules.pro
vendored
1
common/data/proguard-rules.pro
vendored
@@ -19,3 +19,4 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ private const val DATASTORE_NAME = "navigation_settings"
|
||||
class DataStoreManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
|
||||
}
|
||||
|
||||
// Keys
|
||||
private object PreferencesKeys {
|
||||
object PreferencesKeys {
|
||||
|
||||
val SHOW_3D = booleanPreferencesKey("Show3D")
|
||||
|
||||
@@ -45,7 +45,7 @@ class DataStoreManager(private val context: Context) {
|
||||
|
||||
val RECENT_PLACES = stringPreferencesKey("RecentPlaces")
|
||||
|
||||
val FAVORITES = stringPreferencesKey("Favorites")
|
||||
val DISTANCE_MODE = intPreferencesKey("DistanceMode")
|
||||
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class DataStoreManager(private val context: Context) {
|
||||
val routingEngineFlow: Flow<Int> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.ROUTING_ENGINE]
|
||||
?: 0
|
||||
?: 2
|
||||
}
|
||||
|
||||
val lastRouteFlow: Flow<String> =
|
||||
@@ -100,10 +100,11 @@ class DataStoreManager(private val context: Context) {
|
||||
?: ""
|
||||
}
|
||||
|
||||
val favoritesFlow: Flow<String> =
|
||||
|
||||
val distanceModeFlow: Flow<Int> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.FAVORITES]
|
||||
?: ""
|
||||
preferences[PreferencesKeys.DISTANCE_MODE]
|
||||
?: 0
|
||||
}
|
||||
|
||||
// Save values
|
||||
@@ -161,9 +162,10 @@ class DataStoreManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFavorites(apiKey: String) {
|
||||
suspend fun setDistanceMode(mode: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[PreferencesKeys.FAVORITES] = apiKey
|
||||
prefs[PreferencesKeys.DISTANCE_MODE] = mode
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.kouros.navigation.data.route
|
||||
data class Summary(
|
||||
// sec
|
||||
var duration: Double = 0.0,
|
||||
// km
|
||||
// m
|
||||
var distance: Double = 0.0,
|
||||
// sec
|
||||
var trafficDelay: Double = 0.0,
|
||||
// m
|
||||
var trafficLength: Double = 0.0,
|
||||
)
|
||||
@@ -25,7 +25,9 @@ class TomTomRoute {
|
||||
var points = listOf<List<Double>>()
|
||||
val summary = Summary(
|
||||
route.summary.travelTimeInSeconds.toDouble(),
|
||||
route.summary.lengthInMeters.toDouble()
|
||||
route.summary.lengthInMeters.toDouble(),
|
||||
route.summary.trafficDelayInSeconds.toDouble(),
|
||||
route.summary.trafficLengthInMeters.toDouble()
|
||||
)
|
||||
route.legs.forEach { leg ->
|
||||
points = decodePolyline(leg.encodedPolyline, leg.encodedPolylinePrecision)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.kouros.navigation.model
|
||||
//import com.kouros.navigation.data.Preferences.boxStore
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
|
||||
|
||||
@@ -69,7 +69,7 @@ class RouteCalculator(var routeModel: RouteModel) {
|
||||
distance
|
||||
}
|
||||
}
|
||||
return (leftDistance / 10.0).roundToInt() * 10.0
|
||||
return leftDistance.toDouble()
|
||||
}
|
||||
|
||||
/** Returns the left distance in m. */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.kouros.navigation.model
|
||||
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kouros.navigation.data.datastore.DataStoreManager.Companion.dataStore
|
||||
import com.kouros.navigation.data.datastore.DataStoreManager.PreferencesKeys
|
||||
import com.kouros.navigation.repository.SettingsRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -63,6 +66,12 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
|
||||
""
|
||||
)
|
||||
|
||||
val distanceMode = repository.distanceModeFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
0
|
||||
)
|
||||
|
||||
fun onShow3DChanged(enabled: Boolean) {
|
||||
viewModelScope.launch { repository.setShow3D(enabled) }
|
||||
}
|
||||
@@ -94,4 +103,9 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
|
||||
fun onTomTomApiKeyChanged(key: String) {
|
||||
viewModelScope.launch { repository.setTomTomApiKey(key) }
|
||||
}
|
||||
|
||||
fun onDistanceModeChanged(mode: Int) {
|
||||
viewModelScope.launch { repository.setDistanceMode(mode) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ class SettingsRepository(
|
||||
val recentPlacesFlow: Flow<String> =
|
||||
dataStoreManager.recentPlacesFlow
|
||||
|
||||
val favoritesFlow: Flow<String> =
|
||||
dataStoreManager.favoritesFlow
|
||||
val distanceModeFlow: Flow<Int> =
|
||||
dataStoreManager.distanceModeFlow
|
||||
|
||||
|
||||
suspend fun setShow3D(enabled: Boolean) {
|
||||
@@ -73,7 +73,9 @@ class SettingsRepository(
|
||||
dataStoreManager.setRecentPlaces(places)
|
||||
}
|
||||
|
||||
suspend fun setFavorites(favorites: String) {
|
||||
dataStoreManager.setFavorites(favorites)
|
||||
suspend fun setDistanceMode(mode: Int) {
|
||||
dataStoreManager.setDistanceMode(mode)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.kouros.navigation.utils
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import androidx.car.app.model.Distance
|
||||
import com.kouros.navigation.data.RouteEngine
|
||||
import com.kouros.navigation.data.osrm.OsrmRepository
|
||||
import com.kouros.navigation.data.tomtom.TomTomRepository
|
||||
@@ -16,6 +17,7 @@ import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
@@ -110,3 +112,33 @@ fun duration(preview: Boolean, bearing: Double, lastBearing: Double): Duration {
|
||||
}
|
||||
return cameraDuration
|
||||
}
|
||||
|
||||
fun isMetricSystem(): Boolean {
|
||||
val country = Locale.getDefault().country
|
||||
// Return true for metric, false for imperial
|
||||
return !setOf("US", "UK", "LR", "MM").contains(country)
|
||||
}
|
||||
|
||||
fun formattedDistance(distanceMode : Int, distance: Double): Pair<Double, Int> {
|
||||
var currentDistance = distance
|
||||
var displayUnit: Int
|
||||
if (distanceMode == 1 || distanceMode == 0 && isMetricSystem()) {
|
||||
displayUnit = if (currentDistance > 1000.0) {
|
||||
currentDistance /= 1000.0
|
||||
Distance.UNIT_KILOMETERS
|
||||
} else {
|
||||
currentDistance = (currentDistance / 10.0).roundToInt() * 10.0
|
||||
Distance.UNIT_METERS
|
||||
}
|
||||
} else {
|
||||
currentDistance *= 0.621371
|
||||
displayUnit = if (currentDistance > 1000.0) {
|
||||
currentDistance /= 1000.0
|
||||
Distance.UNIT_MILES
|
||||
} else {
|
||||
currentDistance = (currentDistance / 10.0).roundToInt() * 10.0
|
||||
Distance.UNIT_FEET
|
||||
}
|
||||
}
|
||||
return Pair(currentDistance, displayUnit)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.kouros.navigation.utils
|
||||
|
||||
import android.content.Context
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -13,6 +14,9 @@ import com.kouros.navigation.model.SettingsViewModel
|
||||
import com.kouros.navigation.repository.SettingsRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@Composable
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
BIN
common/data/src/main/res/drawable/right_x_straight_o.png
Normal file
BIN
common/data/src/main/res/drawable/right_x_straight_o.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -36,8 +36,8 @@
|
||||
<string name="use_telephon_settings">Verwende Telefon Einstellungen</string>
|
||||
<string name="threed_building">3D Gebäude</string>
|
||||
<string name="drive_now">Losfahren</string>
|
||||
<string name="avoid_tolls_row_title" msgid="5194057244144831024">"Mautstraßen meiden"</string>
|
||||
<string name="avoid_highways_row_title" msgid="4711913426200490304">"Autobahnen meiden"</string>
|
||||
<string name="avoid_tolls_row_title" msgid="5194057244144831024">"Mautstraßen vermeiden"</string>
|
||||
<string name="avoid_highways_row_title" msgid="4711913426200490304">"Autobahnen vermeiden"</string>
|
||||
<string name="recent_destinations">Letzte Ziele</string>
|
||||
<string name="contacts">Kontakte</string>
|
||||
<string name="route_preview">Route Vorschau</string>
|
||||
@@ -54,5 +54,8 @@
|
||||
<string name="use_car_settings">Verwende Auto Einstellungen</string>
|
||||
<string name="exit_number">Ausfahrt nummer</string>
|
||||
<string name="navigation_icon_description">Navigations Icon</string>
|
||||
|
||||
<string name="distance_units">Entfernungseinheiten</string>
|
||||
<string name="automaticaly">Automatisch</string>
|
||||
<string name="kilometer">Kilometer</string>
|
||||
<string name="miles">Meilen</string>
|
||||
</resources>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<string name="avoid_highways_row_title">Avoid highways</string>
|
||||
<string name="avoid_tolls_row_title">Avoid tolls rows</string>
|
||||
<string name="no_places">No places</string>
|
||||
<string name="recent_destinations">Recent destination</string>
|
||||
<string name="recent_destinations">Recent destinations</string>
|
||||
<string name="contacts">Contacts</string>
|
||||
<string name="favorites">Favorites</string>
|
||||
<string name="recent_Item_deleted">Recent item deleted</string>
|
||||
@@ -40,4 +40,8 @@
|
||||
<string name="use_car_settings">Use car settings</string>
|
||||
<string name="exit_number">Exit number</string>
|
||||
<string name="navigation_icon_description">Navigation icon</string>
|
||||
<string name="distance_units">Distance units</string>
|
||||
<string name="automaticaly">Automaticaly</string>
|
||||
<string name="kilometer">Kilometer</string>
|
||||
<string name="miles">Miles</string>
|
||||
</resources>
|
||||
@@ -35,33 +35,33 @@ class IconMapperTest {
|
||||
|
||||
@Test
|
||||
fun `addLanes returns correct lane direction`() {
|
||||
val stepDataNormalLeft = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
|
||||
val stepDataNormalLeft = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_LEFT, iconMapper.addLanes("left_straight", stepDataNormalLeft))
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_LEFT, iconMapper.addLanes("left", stepDataNormalLeft))
|
||||
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("left_slight", stepDataNormalLeft))
|
||||
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("slight_left", stepDataNormalLeft))
|
||||
|
||||
val stepDataStraight = StepData("", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
|
||||
val stepDataStraight = StepData("", "", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("left_straight", stepDataStraight))
|
||||
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataStraight))
|
||||
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("right_straight", stepDataStraight))
|
||||
|
||||
val stepDataKeepLeft = StepData("", 0.0, Maneuver.TYPE_KEEP_LEFT, 0, 0L, 0.0)
|
||||
val stepDataKeepLeft = StepData("", "", 0.0, Maneuver.TYPE_KEEP_LEFT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataKeepLeft))
|
||||
assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, iconMapper.addLanes("left_slight", stepDataKeepLeft))
|
||||
|
||||
val stepDataKeepRight = StepData("", 0.0, Maneuver.TYPE_KEEP_RIGHT, 0, 0L, 0.0)
|
||||
val stepDataKeepRight = StepData("", "", 0.0, Maneuver.TYPE_KEEP_RIGHT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_STRAIGHT, iconMapper.addLanes("straight", stepDataKeepRight))
|
||||
|
||||
val stepDataNormalRight = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
|
||||
val stepDataNormalRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right", stepDataNormalRight))
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right_straight", stepDataNormalRight))
|
||||
|
||||
val stepDataSlightRight = StepData("", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
|
||||
val stepDataSlightRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("right_slight", stepDataSlightRight))
|
||||
assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, iconMapper.addLanes("slight_right", stepDataSlightRight))
|
||||
|
||||
val stepDataUnknown = StepData("", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
|
||||
val stepDataUnknown = StepData("", "", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
|
||||
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("left_straight", stepDataUnknown))
|
||||
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("left", stepDataUnknown))
|
||||
assertEquals(LaneDirection.SHAPE_UNKNOWN, iconMapper.addLanes("straight", stepDataUnknown))
|
||||
@@ -74,24 +74,24 @@ class IconMapperTest {
|
||||
|
||||
@Test
|
||||
fun `laneToResource returns correct resource string`() {
|
||||
val stepDataNormalLeft = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
|
||||
val stepDataNormalLeft = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_LEFT, 0, 0L, 0.0)
|
||||
assertEquals("left_o_straight_x", iconMapper.laneToResource(listOf("left", "straight"), stepDataNormalLeft))
|
||||
assertEquals("left_o", iconMapper.laneToResource(listOf("left"), stepDataNormalLeft))
|
||||
assertEquals("slight_left_o", iconMapper.laneToResource(listOf("slight_left"), stepDataNormalLeft))
|
||||
|
||||
val stepDataStraight = StepData("", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
|
||||
val stepDataStraight = StepData("", "", 0.0, Maneuver.TYPE_STRAIGHT, 0, 0L, 0.0)
|
||||
assertEquals("left_x_straight_o", iconMapper.laneToResource(listOf("left", "straight"), stepDataStraight))
|
||||
assertEquals("straight_o", iconMapper.laneToResource(listOf("straight"), stepDataStraight))
|
||||
assertEquals("right_x_straight_o", iconMapper.laneToResource(listOf("right_straight"), stepDataStraight))
|
||||
|
||||
val stepDataNormalRight = StepData("", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
|
||||
val stepDataNormalRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_NORMAL_RIGHT, 0, 0L, 0.0)
|
||||
assertEquals("right_x_straight_x", iconMapper.laneToResource(listOf("right_straight"), stepDataNormalRight))
|
||||
assertEquals("right_o", iconMapper.laneToResource(listOf("right"), stepDataNormalRight))
|
||||
|
||||
val stepDataSlightRight = StepData("", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
|
||||
val stepDataSlightRight = StepData("", "", 0.0, Maneuver.TYPE_TURN_SLIGHT_RIGHT, 0, 0L, 0.0)
|
||||
assertEquals("right_o_straight_o", iconMapper.laneToResource(listOf("right_straight"), stepDataSlightRight))
|
||||
|
||||
val stepDataUnknown = StepData("", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
|
||||
val stepDataUnknown = StepData("", "", 0.0, Maneuver.TYPE_UNKNOWN, 0, 0L, 0.0)
|
||||
assertEquals("left_x_straight_x", iconMapper.laneToResource(listOf("left", "straight"), stepDataUnknown))
|
||||
assertEquals("", iconMapper.laneToResource(listOf("invalid"), stepDataUnknown))
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "m
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-kapt = { id = "com.android.legacy-kapt", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user