Compare commits

...
58 Commits
Author SHA1 Message Date
Dimitris b99ebfd36f Service 2026-04-04 13:16:15 +02:00
Dimitris 69b27d3b6c Service 2026-04-04 09:39:54 +02:00
Dimitris 8b886c36b1 Service 2026-04-03 13:23:26 +02:00
Dimitris 2ce079a7c1 Service 2026-04-03 12:30:39 +02:00
Dimitris 8af2d3ad0b Service 2026-04-03 10:00:00 +02:00
Dimitris 1d67b3cc06 Arrival Issue 2026-04-03 09:59:32 +02:00
Dimitris a4227c80d3 NavigationService 2026-04-03 09:57:57 +02:00
Dimitris 757c4c8d8d NavigationService 2026-04-03 09:57:52 +02:00
Dimitris 24173412e8 Automotive, CarSession 2026-04-03 09:54:43 +02:00
Dimitris 52f8dec2e6 Stopover and Legs, ManeuverType 2026-04-01 16:24:51 +02:00
Dimitris 6838ad09c4 Stopover and Legs 2026-03-31 17:06:55 +02:00
Dimitris 60b842d883 Nominatim 2026-03-30 10:00:16 +02:00
Dimitris 9def7a5c64 Nominatim 2026-03-30 09:58:13 +02:00
Dimitris bd8a497fbe Diverse 2026-03-30 09:18:04 +02:00
Dimitris 8ca450cd10 Camerat Duration 3 seconds 2026-03-28 13:43:36 +01:00
Dimitris 94d6d6d311 Overlay icons with paint 2026-03-28 13:37:05 +01:00
Dimitris d81d33df43 Arrival Issue 2026-03-28 12:41:12 +01:00
Dimitris 5317a14fb3 Arrival Issue 2026-03-28 11:17:51 +01:00
Dimitris 90010d91b7 Notification 2026-03-27 13:40:14 +01:00
Dimitris 2348d3b633 Duration Map 2026-03-27 07:17:53 +01:00
Dimitris 263b5b576d Navigation Screen to Session, Remove NavigationService 2026-03-26 17:16:04 +01:00
Dimitris aaa57c14b8 Duration Map 2026-03-26 17:14:43 +01:00
Dimitris a3370e42a8 Cluster 2026-03-26 07:13:31 +01:00
Dimitris 5098dad9d6 Launcher Icons, NotificationService 2026-03-24 17:04:05 +01:00
Dimitris bc8a53a5d8 Preview 2026-03-23 07:10:26 +01:00
Dimitris d1968cfa68 Preview 2026-03-21 12:24:53 +01:00
Dimitris ada878b23c Preview 2026-03-16 15:09:03 +01:00
Dimitris 5198725879 Lanes 2026-03-15 17:18:02 +01:00
Dimitris 619ceb9f83 Preview 2026-03-12 15:34:34 +01:00
Dimitris 61ce09f393 Categories 2026-03-09 18:55:13 +01:00
Dimitris 8c103a1f96 Auto Drive Simulation 2026-03-05 13:42:51 +01:00
Dimitris e582c1e0dc Audio guidance 2026-03-04 15:50:21 +01:00
Dimitris 11e9dbb21e Distance settings 2026-03-03 16:34:03 +01:00
Dimitris e1af3e19fa Test, Lanes 2026-03-02 17:11:19 +01:00
Dimitris 378ee8c227 Permission 2026-03-02 07:10:09 +01:00
Dimitris a468529ca4 Testing, Remove ObjectBox 2026-02-28 13:10:48 +01:00
Dimitris eb6d3e4ef7 Claude refactoring 2026-02-25 11:09:31 +01:00
Dimitris 5a6165dff8 SheetContent Simulation 2026-02-25 09:48:39 +01:00
Dimitris e4b539c4e6 Diverse Änderungen 2026-02-24 16:29:13 +01:00
Dimitris 71d3d17847 Settings 2026-02-21 11:12:22 +01:00
Dimitris 723707dac6 Settings 2026-02-20 15:00:47 +01:00
Dimitris ebd97cf1c9 DataStore Manager 2026-02-20 07:20:04 +01:00
Dimitris 65ff41995d TomTom Routing 2026-02-10 10:50:42 +01:00
Dimitris 5141041b5c TomTom Routing 2026-02-09 13:36:05 +01:00
Dimitris e9474695bf TomTom Routing 2026-02-09 08:44:57 +01:00
Dimitris 0d51c6121d TomTom Routing 2026-02-07 12:56:45 +01:00
Dimitris eac5b56bcb Before TomTom Routing 2026-01-29 12:13:37 +01:00
Dimitris 7db7cba4fb Lanes 2026-01-20 13:21:58 +01:00
Dimitris e22865bd73 Lanes 2026-01-12 13:12:22 +01:00
Dimitris e274011080 Diverse 2026-01-10 12:48:41 +01:00
Dimitris 7efa2685be Lanes 2026-01-06 08:25:27 +01:00
Dimitris fdf2ee9f48 CarInfo and CarSensors Osrm 2026-01-03 14:04:50 +01:00
Dimitris 1eab4f1aa3 CarInfo and CarSensors 2025-12-31 11:16:41 +01:00
Dimitris 9f356bd728 Osrm 2025-12-30 16:11:30 +01:00
Dimitris 45c8bb5ccc Strings (de) 2025-12-29 15:47:07 +01:00
Dimitris 82027dce76 Refactoring Route, Speed 2025-12-29 15:44:52 +01:00
Dimitris 1b8abbd4eb Refactoring Route, Speed 2025-12-24 09:55:53 +01:00
Dimitris d0a07e1315 Umbau 2025-12-24 07:24:12 +01:00
253 changed files with 13797 additions and 4827 deletions
+200
View File
@@ -0,0 +1,200 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is an Android navigation app built with Jetpack Compose that supports multiple routing providers (OSRM, Valhalla, TomTom) and includes Android Auto/Automotive OS integration. The app uses MapLibre for rendering, ObjectBox for local persistence, and Koin for dependency injection.
## Build Commands
```bash
# Build the app (from repository root)
./gradlew :app:assembleDebug
# Build specific flavor
./gradlew :app:assembleDemoDebug
./gradlew :app:assembleFullDebug
# Run tests
./gradlew test
# Run tests for specific module
./gradlew :common:data:test
./gradlew :common:car:test
# Install on device
./gradlew :app:installDebug
# Clean build
./gradlew clean
```
## Module Structure
The project uses a multi-module architecture:
- **app/** - Main Android app with Jetpack Compose UI for phone
- **common/data/** - Core data layer with routing logic, repositories, and data models (shared by all modules)
- **common/car/** - Android Auto/Automotive OS UI implementation
- **automotive/** - Placeholder for future native Automotive OS app
Dependencies flow: `app``common:car``common:data`
## Architecture
### Routing Providers (Pluggable System)
The app supports three routing engines that implement the `NavigationRepository` abstract class:
1. **OsrmRepository** - OSRM routing engine
2. **ValhallaRepository** - Valhalla routing engine
3. **TomTomRepository** - TomTom routing engine
Each provider has a corresponding mapper class (`OsrmRoute`, `ValhallaRoute`, `TomTomRoute`) that converts provider-specific JSON responses to the universal `Route` data model.
**Adding a new routing provider:**
1. Create `NewProviderRepository` extending `NavigationRepository` in `common/data/src/main/java/com/kouros/navigation/data/`
2. Implement `getRoute()` method
3. Create `NewProviderRoute.kt` with `mapToRoute()` function
4. Add provider detection logic in `Route.Builder.route()`
5. Update `NavigationUtils.getViewModel()` to return appropriate ViewModel
### Data Flow
```
User Action (search/select destination)
ViewModel.loadRoute() [LiveData]
NavigationRepository.getRoute() [Selected provider]
*Route.mapToRoute() [Convert to universal Route model]
RouteModel.startNavigation()
RouteModel.updateLocation() [On each location update]
UI observes LiveData and displays current step
```
### Key Classes
**Navigation Logic:**
- `RouteModel.kt` - Core navigation engine (tracks position, calculates distances, manages steps)
- `RouteCarModel.kt` - Extends RouteModel with Android Auto-specific formatting
- `ViewModel.kt` - androidx.ViewModel with LiveData for route, traffic, places, etc.
**Data Models:**
- `Route.kt` - Universal route structure used by all providers
- `Place.kt` - ObjectBox entity for favorites/recent locations
- `StepData.kt` - Display data for current navigation instruction
**Repositories:**
- `NavigationRepository.kt` - Abstract base class for all routing providers
- Also handles Nominatim geocoding search and TomTom traffic incidents
**Android Auto:**
- `NavigationCarAppService.kt` - Entry point for Android Auto/Automotive OS
- `NavigationSession.kt` - Session management
- `NavigationScreen.kt` - Car screen templates with NavigationType state machine
- `SurfaceRenderer.kt` - Handles virtual display and map rendering
### External APIs
| Service | Purpose | Base URL |
|---------|---------|----------|
| OSRM | Routing | `https://kouros-online.de/osrm/route/v1/driving/` |
| Valhalla | Routing | `https://kouros-online.de/valhalla/route` |
| TomTom | Traffic incidents | `https://api.tomtom.com/traffic/services/5/incidentDetails` |
| Nominatim | Geocoding search | `https://kouros-online.de/nominatim/` |
| Overpass | POI & speed limits | OpenStreetMap Overpass API |
## Important Constants
Located in `Constants.kt` (`common/data`):
```kotlin
NEXT_STEP_THRESHOLD = 120.0 m // Distance to show next maneuver
DESTINATION_ARRIVAL_DISTANCE = 40.0 m // Distance to trigger arrival
MAXIMAL_SNAP_CORRECTION = 50.0 m // Max distance to snap to route
MAXIMAL_ROUTE_DEVIATION = 80.0 m // Max deviation before reroute
```
SharedPreferences keys:
- `ROUTING_ENGINE` - Selected provider (0=Valhalla, 1=OSRM, 2=TomTom)
- `DARK_MODE_SETTINGS` - Theme preference
- `AVOID_MOTORWAY`, `AVOID_TOLLWAY` - Route preferences
## Navigation Flow
1. **Route Loading**: User searches via Nominatim → selects place → ViewModel.loadRoute() calls selected repository
2. **Route Parsing**: Provider JSON → mapper converts to universal Route → RouteModel.startNavigation()
3. **Location Tracking**: FusedLocationProviderClient provides updates → RouteModel.updateLocation()
4. **Step Calculation**: findStep() snaps location to nearest waypoint → updates current step
5. **UI Updates**: currentStep() and nextStep() provide display data (instruction, distance, icon, lanes)
6. **Arrival**: When distance < DESTINATION_ARRIVAL_DISTANCE, navigation ends
## Testing Navigation
The app includes mock location support for testing:
- Set `useMock = true` in MainActivity
- Enable "Mock location app" in Android Developer Options
- Choose test mode:
- `type = 1` - Simulate movement along entire route
- `type = 2` - Test specific step range
- `type = 3` - Replay GPX track file
## ObjectBox Database
ObjectBox is configured in `common/data/build.gradle.kts` with the kapt plugin. The database stores:
- Recent destinations (category: "Recent")
- Favorite places (category: "Favorites")
- Imported contacts (category: "Contacts")
Queries use ObjectBox query builder pattern with generated `Place_` property accessors.
## Compose UI Structure
**Phone App:**
- `MainActivity.kt` - Main entry with permission handling and Navigation Compose
- `NavigationScreen.kt` - Turn-by-turn navigation display
- `SearchSheet.kt` / `NavigationSheet.kt` - Bottom sheet content
- `MapView.kt` - MapLibre rendering with camera state management
**Android Auto:**
- Uses CarAppService Screen templates (NavigationTemplate, MessageTemplate, MapWithContentTemplate)
- NavigationType enum controls which template to display (VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL)
## Build Flavors
Two product flavors with dimension "version":
- **demo** - applicationId: `com.kouros.navigation.demo`
- **full** - applicationId: `com.kouros.navigation.full`
## Common Patterns
**Dependency Injection (Koin):**
```kotlin
single { OsrmRepository() }
viewModel { ViewModel(get()) }
```
**LiveData Observation:**
```kotlin
viewModel.route.observe(this) { routeJson ->
routeModel.startNavigation(routeJson, context)
}
```
**Step Finding Algorithm:**
RouteModel iterates through all step waypoints, calculates distance to current location, and snaps to the nearest waypoint to determine current step index.
## Known Limitations
- Valhalla route mapping is incomplete (search for TODO comments in ValhallaRoute.kt)
- Rerouting logic exists but needs more testing
- Speed limit queries via Overpass API could be optimized for performance
- TomTom implementation uses local JSON file (R.raw.tomom_routing) instead of live API
+10
View File
@@ -0,0 +1,10 @@
# README.md
## Introduction
## Simulation
adb shell dumpsys activity service com.kouros.navigation.car.NavigationCarAppService AUTO_DRIVE
## Signing
./gradlew bundleFull
+44 -23
View File
@@ -1,9 +1,12 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
val properties = Properties().apply {
load(File("signing.properties").reader())
}
android {
@@ -14,8 +17,8 @@ android {
applicationId = "com.kouros.navigation"
minSdk = 33
targetSdk = 36
versionCode = 14
versionName = "0.1.3.14"
versionCode = 91
versionName = "0.2.3.91"
base.archivesName = "navi-$versionName"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -23,21 +26,24 @@ android {
signingConfigs {
getByName("debug") {
keyAlias = "release"
keyPassword = "zeta67#gAe3aN3"
storeFile = file("/home/kouros/work/keystore/keystoreRelease")
storePassword = "zeta67#gAe3aN3"
keyPassword = properties.getProperty("keyPassword")
storeFile = file(properties.getProperty("storeFile"))
storePassword = properties.getProperty("storePassword")
}
create("release") {
keyAlias = "release"
keyPassword = "zeta67#gAe3aN3"
storeFile = file("/home/kouros/work/keystore/keystoreRelease")
storePassword = "zeta67#gAe3aN3"
keyPassword = properties.getProperty("keyPassword")
storeFile = file(properties.getProperty("storeFile"))
storePassword = properties.getProperty("storePassword")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -45,30 +51,42 @@ android {
}
}
// Specifies one flavor dimension.
flavorDimensions += "version"
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
applicationIdSuffix = ".play"
versionNameSuffix = "-play"
}
create("demo") {
dimension = "version"
dimension = "store"
applicationIdSuffix = ".demo"
versionNameSuffix = "-demo"
}
create("full") {
dimension = "version"
dimension = "store"
applicationIdSuffix = ".full"
versionNameSuffix = "-full"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
)
}
}
buildFeatures {
compose = true
buildConfig = true
}
}
@@ -81,11 +99,12 @@ dependencies {
implementation(libs.androidx.runtime.livedata)
implementation(libs.koin.androidx.compose)
implementation(libs.maplibre.compose)
//implementation(libs.maplibre.composeMaterial3)
implementation(libs.accompanist.permissions)
implementation(project(":common:data"))
implementation(libs.accompanist.permissions)
implementation(project(":common:car"))
implementation(project(":common:data"))
implementation(libs.androidx.car.app)
implementation(libs.androidx.app.projected)
implementation(libs.play.services.location)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.navigation.compose)
@@ -94,7 +113,10 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.window)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.foundation)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -102,4 +124,3 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
}
+17 -1
View File
@@ -6,7 +6,11 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- <uses-permission android:name="android.permission.READ_CONTACTS"/>-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- <uses-permission android:name="android.permission.READ_CONTACTS"/>-->
<uses-permission android:name="android.permission.ACCESS_MOCK_LOCATION"
tools:ignore="MockLocation" />
@@ -20,6 +24,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Navigation">
<meta-data
@@ -35,6 +40,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".car.NavigationNotificationService"
android:foregroundServiceType="location"
android:exported="true">
</service>
<service
android:name=".car.navigation.NavigationService"
android:enabled="true"
android:foregroundServiceType="location"
android:exported="true">
</service>
</application>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 28 KiB

@@ -2,22 +2,20 @@ package com.kouros.navigation
import android.app.Application
import android.content.Context
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.di.appModule
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
ObjectBox.init(this);
appContext = applicationContext
navigationViewModel = getViewModel(appContext!!)
startKoin {
androidLogger(Level.DEBUG)
androidContext(this@MainApplication)
@@ -26,11 +24,10 @@ class MainApplication : Application() {
}
companion object {
var appContext: Context? = null
private set
var useContacts = false
val navigationViewModel = ViewModel(ValhallaRepository())
lateinit var navigationViewModel : NavigationViewModel
}
}
@@ -1,14 +1,20 @@
package com.kouros.navigation.di
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val appModule = module {
viewModelOf(::ViewModel)
viewModelOf(::NavigationViewModel)
viewModelOf(::SettingsViewModel)
singleOf(::ValhallaRepository)
}
singleOf(::OsrmRepository)
singleOf(::TomTomRepository)
}
@@ -7,11 +7,16 @@ import android.os.SystemClock
class MockLocation (private var locationManager: LocationManager) {
var curSpeed = 0F
fun setMockLocation(latitude: Double, longitude: Double) {
fun setMockLocation(latitude: Double, longitude: Double, bearing : Float) {
try {
// Set mock location for all providers
setMockLocationForProvider(LocationManager.GPS_PROVIDER, latitude, longitude)
setMockLocationForProvider(LocationManager.NETWORK_PROVIDER, latitude, longitude)
setMockLocationForProvider(LocationManager.GPS_PROVIDER, latitude, longitude, bearing)
setMockLocationForProvider(
LocationManager.NETWORK_PROVIDER,
latitude,
longitude,
bearing
)
} catch (e: NumberFormatException) {
} catch (e: SecurityException) {
} catch (e: Exception) {
@@ -19,7 +24,12 @@ class MockLocation (private var locationManager: LocationManager) {
}
}
private fun setMockLocationForProvider(provider: String, latitude: Double, longitude: Double) {
private fun setMockLocationForProvider(
provider: String,
latitude: Double,
longitude: Double,
bearing: Float
) {
try {
// Check if provider exists
if (!locationManager.allProviders.contains(provider)) {
@@ -48,14 +58,14 @@ class MockLocation (private var locationManager: LocationManager) {
this.latitude = latitude
this.longitude = longitude
this.altitude = 0.0
this.accuracy = 1.0f
this.speed = 10f
this.accuracy = 0f
this.speed = 0f
this.time = System.currentTimeMillis()
this.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
this.bearingAccuracyDegrees = 0.0f
this.verticalAccuracyMeters = 0.0f
this.speedAccuracyMetersPerSecond = 0.0f
this.bearing = bearing
}
// Set the mock location
locationManager.setTestProviderLocation(provider, mockLocation)
@@ -71,14 +81,15 @@ class MockLocation (private var locationManager: LocationManager) {
this.latitude = latitude
this.longitude = longitude
this.altitude = 0.0
this.accuracy = 1.0f
this.speed = 10f
this.accuracy = 0f
this.speed = 0f
this.time = System.currentTimeMillis()
this.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
this.bearingAccuracyDegrees = 0.0f
this.verticalAccuracyMeters = 0.0f
this.speedAccuracyMetersPerSecond = 0.0f
this.bearing = bearing
}
locationManager.setTestProviderLocation(provider, mockLocation)
} catch (ex: Exception) {
@@ -88,3 +99,4 @@ class MockLocation (private var locationManager: LocationManager) {
}
}
@@ -0,0 +1,88 @@
package com.kouros.navigation.model
import android.content.Context
import com.kouros.navigation.MainApplication.Companion.navigationViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
var simulationJob: Job? = null
fun simulate(routeModel: RouteModel, mock: MockLocation) {
simulationJob?.cancel()
simulationJob = CoroutineScope(Dispatchers.IO).launch {
var lastLocation = location(0.0, 0.0)
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 0..routeModel.curRoute.waypoints.size) {
val bearing = lastLocation.bearingTo(curLocation)
mock.setMockLocation(waypoint[1], waypoint[0], bearing)
Thread.sleep(1000)
}
}
lastLocation = curLocation
}
}
}
fun test(applicationContext: Context, routeModel: RouteModel) {
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation(
location(waypoint[0], waypoint[1]), navigationViewModel
)
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
println("Step: ${step.instruction} ${step.leftStepDistance} ${nextStep.currentManeuverType}")
}
}
}
fun testSingle(applicationContext: Context, routeModel: RouteModel, mock: MockLocation) {
testSingleUpdate(
applicationContext,
48.185976,
11.578463,
routeModel,
mock
) // Silcherstr. 23-13
testSingleUpdate(
applicationContext,
48.186712,
11.578574,
routeModel,
mock
) // Silcherstr. 27-33
testSingleUpdate(
applicationContext,
48.186899,
11.580480,
routeModel,
mock
) // Schmalkadenerstr. 24-28
}
fun testSingleUpdate(
applicationContext: Context,
latitude: Double,
longitude: Double,
routeModel: RouteModel,
mock: MockLocation
) {
if (1 == 1) {
mock.setMockLocation(latitude, longitude, 0F)
} else {
routeModel.updateLocation(
location(longitude, latitude), navigationViewModel
)
}
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
Thread.sleep(1_000)
}
enum class SimulationType {
SIMULATE, TEST, GPX, TEST_SINGLE
}
@@ -1,325 +1,339 @@
package com.kouros.navigation.ui
import NavigationSheet
import android.Manifest
import android.annotation.SuppressLint
import android.app.AppOpsManager
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.location.LocationManager
import android.os.Bundle
import android.os.Process
import android.widget.Toast
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.kouros.data.R
import com.kouros.navigation.MainApplication.Companion.navigationViewModel
import com.kouros.navigation.car.TextToSpeechManager
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Constants.homeLocation
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.Constants.INSTRUCTION_DISTANCE
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TILT
import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.MockLocation
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.model.SimulationType
import com.kouros.navigation.model.simulationJob
import com.kouros.navigation.ui.app.AppViewModel
import com.kouros.navigation.ui.app.appViewModel
import com.kouros.navigation.ui.navigation.AppNavGraph
import com.kouros.navigation.ui.navigation.NavigationSheet
import com.kouros.navigation.ui.search.SearchSheet
import com.kouros.navigation.ui.theme.NavigationTheme
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.calculateZoom
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberDefaultLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position
import kotlin.time.Duration.Companion.seconds
class MainActivity : ComponentActivity() {
val routeData = MutableLiveData("")
var isBound: Boolean = false
val routeData = MutableLiveData("")
val routeModel = RouteModel()
var tilt = 50.0
val useMock = true
var tilt = TILT
val type = SimulationType.SIMULATE
val stepData: MutableLiveData<StepData> by lazy {
MutableLiveData<StepData>()
MutableLiveData()
}
val nextStepData: MutableLiveData<StepData> by lazy {
MutableLiveData<StepData>()
MutableLiveData()
}
var lastStepIndex = -1
var lastLocation = location(0.0, 0.0)
val observer = Observer<String> { newRoute ->
if (newRoute.isNotEmpty()) {
routeModel.startNavigation(newRoute)
routeData.value = routeModel.route.routeGeoJson
simulate()
//test()
startNavigation(newRoute)
}
}
val cameraPosition = MutableLiveData(
CameraPosition(
zoom = 15.0,
target = Position(latitude = 48.1857475, longitude = 11.5793627)
zoom = 15.0, target = Position(latitude = 48.1857475, longitude = 11.5793627)
)
)
private lateinit var locationManager: LocationManager
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var mock: MockLocation
private var loadRecentPlaces = false
lateinit var textToSpeechManager: TextToSpeechManager
private var overpass = false
var guidanceAudio = 0
init {
navigationViewModel.route.observe(this, observer)
override fun onDestroy() {
if (simulationJob != null) {
simulationJob?.cancel()
}
super.onDestroy()
}
@RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (useMock) {
checkMockLocationEnabled()
}
navigationViewModel.route.value = ""
textToSpeechManager = TextToSpeechManager(applicationContext)
val repository = getSettingsRepository(applicationContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it
})
locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
if (useMock) {
mock = MockLocation(locationManager)
mock.setMockLocation(
homeLocation.latitude,
homeLocation.longitude
)
fusedLocationClient.lastLocation.addOnSuccessListener { _: android.location.Location? ->
navigationViewModel.route.observe(this, observer)
}
lifecycleScope.launch {
getSettingsViewModel(applicationContext).routingEngine.first()
getSettingsViewModel(applicationContext).recentPlaces.first()
}
enableEdgeToEdge()
setContent {
CheckPermissionScreen()
NavigationTheme {
CheckPermissionScreen(app = {
AppNavGraph(
mainActivity = this
)
})
}
}
}
@SuppressLint("MissingPermission")
@Composable
fun CheckPermissionScreen() {
val permissions = listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
)
PermissionScreen(
permissions = permissions,
requiredPermissions = listOf(permissions.first()),
onGranted = {
Content()
},
)
}
@SuppressLint("AutoboxingStateCreation")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Content() {
val scaffoldState = rememberBottomSheetScaffoldState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val sheetPeekHeightState = remember { mutableStateOf(256.dp) }
fun StartScreen(
navController: NavHostController
) {
val appViewModel: AppViewModel = appViewModel()
val darkMode by appViewModel.darkMode.collectAsState()
if (darkMode == 1) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
}
val baseStyle = BaseStyleModel().readStyle(applicationContext, darkMode, darkMode == 1)
val locationProvider = rememberDefaultLocationProvider(
updateInterval = 0.5.seconds,
desiredAccuracy = DesiredAccuracy.Highest
updateInterval = 0.5.seconds, desiredAccuracy = DesiredAccuracy.Highest
)
val lastRoute by appViewModel.lastRoute.collectAsState()
// use not the same route for mobile and car navigation
//if (lastRoute.isNotEmpty()) {
// navigationViewModel.route.value = lastRoute
//}
val userLocationState = rememberUserLocationState(locationProvider)
val locationState = locationProvider.location.collectAsState()
updateLocation(locationState.value)
var latitude by remember { mutableDoubleStateOf(0.0) }
if (locationState.value != null) {
latitude = locationState.value!!.position.latitude
}
val step: StepData? by stepData.observeAsState()
val nextStep: StepData? by nextStepData.observeAsState()
fun openSheet() {
scope.launch { scaffoldState.bottomSheetState.expand() }
NavigationTheme(useDarkTheme = darkMode == 1) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
SheetLayout(
map = { _ ->
Map(
userLocationState, step, nextStep, baseStyle, navController
)
},
menu = { SheetContent(navController, step, nextStep) },
)
}
}
}
fun closeSheet() {
scope.launch {
scaffoldState.bottomSheetState.partialExpand()
sheetPeekHeightState.value = 128.dp
}
}
NavigationTheme {
BottomSheetScaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
scaffoldState = scaffoldState,
sheetPeekHeight = sheetPeekHeightState.value,
sheetContent = {
SheetContent(latitude, step, nextStep) { closeSheet() }
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
@Composable
private fun Map(
userLocationState: UserLocationState,
step: StepData?,
nextStep: StepData?,
baseStyle: BaseStyle.Json,
navController: NavHostController
) {
MapView(
applicationContext,
userLocationState,
step,
nextStep,
cameraPosition,
routeData,
tilt
tilt,
baseStyle,
)
if (!routeModel.isNavigating()) {
Settings(navController, modifier = Modifier.fillMaxWidth())
}
}
@Composable
fun Settings(navController: NavController, modifier: Modifier = Modifier) {
Box(
modifier = Modifier.fillMaxSize()
) {
FloatingActionButton(
modifier = Modifier.padding(start = 10.dp, top = 40.dp),
onClick = {
navController.navigate("settings")
},
) {
Icon(
painter = painterResource(R.drawable.menu_24px),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(24.dp, 24.dp),
)
}
}
}
@Composable
fun SheetContent(
locationState: Double,
step: StepData?,
nextStep: StepData?,
closeSheet: () -> Unit
navController: NavHostController, step: StepData?, nextStep: StepData?
) {
if (!routeModel.isNavigating()) {
SearchSheet(applicationContext, navigationViewModel, lastLocation) { closeSheet() }
SearchSheet(applicationContext, navController, navigationViewModel, lastLocation) { }
} else {
if (step != null) {
NavigationSheet(
routeModel, step!!, nextStep!!,
{ stopNavigation { closeSheet() } },
{ simulateNavigation() }
)
applicationContext,
routeModel,
step,
nextStep,
{ stopNavigation {} },
{ })
}
}
// For recomposition!
Text("$locationState", fontSize = 12.sp)
}
fun updateLocation(location: Location?) {
if (location != null
&& lastLocation.latitude != location.position.latitude
&& lastLocation.longitude != location.position.longitude
) {
if (location != null && lastLocation.latitude != location.position.latitude && lastLocation.longitude != location.position.longitude) {
val currentLocation = location(location.position.longitude, location.position.latitude)
if (location.bearing != null && location.bearingAccuracy!! <= 20.0) {
currentLocation.bearing = location.bearing!!.toFloat()
}
if (routeModel.isNavigating()) {
val snapedLocation =
snapLocation(currentLocation, routeModel.route.maneuverLocations())
updateLocationInternal(currentLocation, location)
} else {
updateLocationInternal(currentLocation, location)
}
}
}
fun updateLocationInternal(currentLocation: android.location.Location, location: Location?) {
if (currentLocation.hasBearing()) {
routeModel.navState = routeModel.navState.copy(routeBearing = currentLocation.bearing)
}
val bearing = if (currentLocation.hasBearing()) {
currentLocation.bearing.toDouble()
} else {
bearing(lastLocation, currentLocation, cameraPosition.value!!.bearing)
}
with(routeModel) {
if (isNavigating()) {
updateLocation(currentLocation, navigationViewModel)
stepData.value = currentStep()
if (route.currentManeuverIndex + 1 <= route.maneuvers.size) {
if (guidanceAudio == 1) {
textToSpeech()
}
if (navState.nextStep) {
nextStepData.value = nextStep()
}
if (routeState.maneuverType == 39
&& leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
stopNavigation()
routeState = routeState.copy(arrived = true)
if (navState.maneuverType in 39..42 && routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE) {
// stopNavigation()
navState = navState.copy(arrived = true)
routeData.value = ""
}
}
}
val bearing = bearing(lastLocation, currentLocation, cameraPosition.value!!.bearing)
val zoom = calculateZoom(location.speed)
//val zoom = calculateZoom(location.speed)
val zoom = 16.0
cameraPosition.postValue(
cameraPosition.value!!.copy(
zoom = zoom,
target = location.position,
bearing = bearing
zoom = zoom, target = location!!.position, bearing = bearing
),
)
lastLocation = currentLocation
if (!loadRecentPlaces) {
navigationViewModel.loadRecentPlaces(applicationContext, lastLocation)
navigationViewModel.loadRecentPlaces(applicationContext, lastLocation, 0F)
loadRecentPlaces = true
}
}
}
fun startNavigation(newRoute: String) {
val repository = getSettingsRepository(applicationContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(newRoute)
routeData.value = routeModel.curRoute.routeGeoJson
}
fun stopNavigation(closeSheet: () -> Unit) {
closeSheet()
routeModel.stopNavigation()
getSettingsViewModel(applicationContext).onLastRouteChanged("")
routeData.value = ""
stepData.value = StepData("", 0.0, 0, 0, 0, 0.0)
stepData.value = StepData("", "", 0.0, 0, 0, 0, 0.0)
}
fun simulateNavigation() {
simulate()
}
private fun checkMockLocationEnabled() {
try {
// Check if mock location is enabled for this app
val appOpsManager =
getSystemService(APP_OPS_SERVICE) as AppOpsManager
val mode =
appOpsManager.checkOp(
AppOpsManager.OPSTR_MOCK_LOCATION,
Process.myUid(),
packageName
)
if (mode != AppOpsManager.MODE_ALLOWED) {
Toast.makeText(
this,
"Please select this app as mock location app in Developer Options",
Toast.LENGTH_LONG
).show()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun simulate() = GlobalScope.async {
for ((_, loc) in routeModel.route.waypoints.withIndex()) {
if (routeModel.isNavigating()) {
mock.setMockLocation(loc[1], loc[0])
delay(500L) //
}
}
}
fun test() {
for ((index, loc) in routeModel.route.waypoints.withIndex()) {
if (index > 300) {
routeModel.updateLocation(location(loc[0], loc[1]), navigationViewModel)
routeModel.currentStep()
if (routeModel.route.currentManeuverIndex + 1 <= routeModel.route.maneuvers.size) {
nextStepData.value = routeModel.nextStep()
}
println(routeModel.routeState.maneuverType)
}
fun textToSpeech() {
val currentStep = routeModel.route.currentStep()
val stepData = routeModel.currentStep()
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) {
textToSpeechManager.speak(stepData.message)
lastStepIndex = currentStep.index
}
}
}
@@ -1,24 +1,25 @@
package com.kouros.navigation.ui
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.window.layout.WindowMetricsCalculator
import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.map.DarkMode
import com.kouros.navigation.car.map.MapLibre
import com.kouros.navigation.car.map.NavigationImage
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.ui.app.AppViewModel
import com.kouros.navigation.ui.app.appViewModel
import com.kouros.navigation.ui.navigation.NavigationInfo
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.location.LocationTrackingEffect
@@ -32,12 +33,15 @@ fun MapView(
applicationContext: Context,
userLocationState: UserLocationState,
step: StepData?,
nextStep: StepData?,
cameraPosition: MutableLiveData<CameraPosition>,
routeData: MutableLiveData<String>,
tilt: Double
tilt: Double,
baseStyle: BaseStyle.Json,
) {
val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(applicationContext)
val metrics =
WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(applicationContext)
val width = metrics.bounds.width()
val height = metrics.bounds.height()
val paddingValues = PaddingValues(start = 0.dp, top = 350.dp)
@@ -55,14 +59,25 @@ fun MapView(
zoom = 15.0,
)
)
val baseStyle = remember {
mutableStateOf(BaseStyle.Uri(Constants.STYLE))
}
DarkMode(applicationContext, baseStyle)
val appViewModel: AppViewModel = appViewModel()
val showBuildings by appViewModel.show3D.collectAsState()
val darkMode by appViewModel.darkMode.collectAsState()
val dark = darkMode == 1 || darkMode == 2 && isSystemInDarkTheme()
Column {
NavigationInfo(step)
NavigationInfo(step, nextStep)
Box(contentAlignment = Alignment.Center) {
MapLibre(applicationContext, cameraState, baseStyle, route, ViewStyle.VIEW)
MapLibre(
cameraState,
baseStyle,
route,
emptyMap(),
ViewStyle.VIEW,
speedCameras = "",
showBuildings
)
LocationTrackingEffect(
locationState = userLocationState,
) {
@@ -77,10 +92,7 @@ fun MapView(
duration = 1.seconds
)
}
NavigationImage(paddingValues, width, height / 6)
NavigationImage(paddingValues, width, height / 6, "", dark)
}
}
}
@@ -1,51 +0,0 @@
package com.kouros.navigation.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.round
@Composable
fun NavigationInfo(step: StepData?) {
if (step != null && step.instruction.isNotEmpty()) {
Card(modifier = Modifier.padding(top = 60.dp)) {
Column() {
Row {
Icon(
painter = painterResource(step.icon),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
Column {
if (step.leftStepDistance < 1000) {
Text(text = "${step.leftStepDistance.toInt()} m", fontSize = 25.sp)
} else {
Text(
text = "${(step.leftStepDistance / 1000).round(1)} km",
fontSize = 25.sp
)
}
Text(text = step.instruction, fontSize = 20.sp)
}
Icon(
painter = painterResource(step.icon),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
}
}
}
}
}
@@ -1,5 +1,7 @@
package com.kouros.navigation.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.animateContentSize
@@ -37,6 +39,25 @@ import androidx.core.net.toUri
*
* By default it assumes that all [permissions] are required.
*/
@SuppressLint("MissingPermission")
@Composable
fun CheckPermissionScreen(
app: @Composable () -> Unit,
) {
val permissions = listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
)
PermissionScreen(
permissions = permissions,
requiredPermissions = listOf(permissions.first()),
onGranted = {
app()
},
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionScreen(
@@ -1,273 +0,0 @@
package com.kouros.navigation.ui
import android.content.Context
import android.location.Location
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import com.kouros.data.R
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.PlaceColor
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.utils.location
@Composable
fun SearchSheet(
applicationContext: Context,
viewModel: ViewModel,
location: Location,
closeSheet: () -> Unit
) {
val searchResults = mutableListOf<SearchResult>()
val recentPlaces = viewModel.places.observeAsState()
val search = viewModel.searchPlaces.observeAsState()
if (search.value != null) {
searchResults.addAll(search.value!!)
}
Home(applicationContext, viewModel, location, closeSheet = { closeSheet() })
if (searchResults.isNotEmpty()) {
val textFieldState = rememberTextFieldState()
val items = listOf(searchResults)
if (items.isNotEmpty()) {
SearchBar(
textFieldState = textFieldState,
searchPlaces = recentPlaces.value!!,
searchResults = searchResults,
viewModel = viewModel,
context = applicationContext,
location = location,
closeSheet = { closeSheet() }
)
}
}
if (recentPlaces.value != null) {
val textFieldState = rememberTextFieldState()
val items = listOf(recentPlaces)
if (items.isNotEmpty()) {
SearchBar(
textFieldState = textFieldState,
searchPlaces = recentPlaces.value!!,
searchResults = searchResults,
viewModel = viewModel,
context = applicationContext,
location = location,
closeSheet = { closeSheet() }
)
}
}
}
@Composable
fun Home(
applicationContext: Context,
viewModel: ViewModel,
location: Location,
closeSheet: () -> Unit
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = {
val places = viewModel.loadRecentPlace()
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, toLocation)
closeSheet()
}) {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Home",
modifier = Modifier.size(24.dp, 24.dp),
)
Text("Home")
}
Button(onClick = {
}) {
Icon(
painter = painterResource(id = R.drawable.ic_favorite_white_24dp),
"Work",
modifier = Modifier.size(24.dp, 24.dp),
)
Text("Arbeit")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
textFieldState: TextFieldState,
searchPlaces: List<Place>,
searchResults: List<SearchResult>,
modifier: Modifier = Modifier,
viewModel: ViewModel,
context: Context,
location: Location,
closeSheet: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(true) }
Box(
modifier
.fillMaxSize()
.semantics { isTraversalGroup = true }
) {
SearchBar(
modifier = Modifier
.align(Alignment.TopCenter)
.semantics { traversalIndex = 0f },
inputField = {
SearchBarDefaults.InputField(
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.search_48px),
"Search",
modifier = Modifier.size(24.dp, 24.dp),
)
},
query = textFieldState.text.toString(),
onQueryChange = { textFieldState.edit { replace(0, length, it) } },
onSearch = {
searchPlaces(viewModel, location, it)
expanded = false
},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text(context.getString(R.string.search_action_title)) }
)
},
expanded = expanded,
onExpandedChange = { expanded = it },
) {
if (searchPlaces.isNotEmpty()) {
Text(context.getString(R.string.recent_destinations))
RecentPlaces(searchPlaces, viewModel, context, location, closeSheet)
}
if (searchResults.isNotEmpty()) {
Text("Search places")
SearchPlaces(searchResults, viewModel, context, location, closeSheet)
}
}
}
}
private fun searchPlaces(viewModel: ViewModel, location: Location, it: String) {
viewModel.searchPlaces(it, location)
}
@Composable
private fun SearchPlaces(
searchResults: List<SearchResult>,
viewModel: ViewModel,
context: Context,
location: Location,
closeSheet: () -> Unit
) {
val color = remember { PlaceColor }
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (searchResults.isNotEmpty()) {
items(searchResults, key = { it.placeId }) { place ->
Row {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Navigation",
tint = color.copy(alpha = 1f),
modifier = Modifier.size(24.dp, 24.dp),
)
ListItem(
headlineContent = { Text("${place.address.road} ${place.address.postcode}") },
modifier = Modifier
.clickable {
val pl = Place(
name = place.name,
longitude = place.lon.toDouble(),
latitude = place.lat.toDouble(),
postalCode = place.address.postcode,
city = place.address.city,
street = place.address.road
)
viewModel.saveRecent(pl)
val toLocation =
location(place.lon.toDouble(), place.lat.toDouble())
viewModel.loadRoute(context, location, toLocation)
closeSheet()
}
.fillMaxWidth()
)
HorizontalDivider(color = Color.Gray)
}
}
}
}
}
@Composable
private fun RecentPlaces(
recentPlaces: List<Place>,
viewModel: ViewModel,
context: Context,
location: Location,
closeSheet: () -> Unit
) {
val color = remember { PlaceColor }
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(recentPlaces, key = { it.id }) { place ->
Row {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Navigation",
tint = color.copy(alpha = 1f),
modifier = Modifier.size(24.dp, 24.dp),
)
ListItem(
headlineContent = { Text("${place.name} ${place.postalCode}") },
modifier = Modifier
.clickable {
val toLocation = location(place.longitude, place.latitude)
viewModel.loadRoute(context, location, toLocation)
closeSheet()
}
.fillMaxWidth()
)
HorizontalDivider(color = Color.Gray)
}
}
}
}
@@ -0,0 +1,82 @@
package com.kouros.navigation.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.kouros.data.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetLayout(
map: @Composable (PaddingValues) -> Unit,
menu: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberBottomSheetScaffoldState()
BottomSheetScaffold(
sheetPeekHeight = 180.dp,
scaffoldState = sheetState,
sheetSwipeEnabled = true,
sheetDragHandle = {
ExpandCollapseButton(
sheetState.bottomSheetState.targetValue == SheetValue.Expanded,
onExpand = { sheetState.bottomSheetState.expand() },
onCollapse = { sheetState.bottomSheetState.partialExpand() },
modifier = Modifier.fillMaxWidth(),
)
},
sheetContent = {
Box(
modifier =
Modifier.background(BottomSheetDefaults.ContainerColor)
.consumeWindowInsets(PaddingValues(top = 56.dp))
.requiredHeight(500.dp)
) {
menu()
}
},
modifier = modifier,
) { padding ->
map(padding)
}
}
@Composable
private fun ExpandCollapseButton(
expanded: Boolean,
onExpand: suspend () -> Unit,
onCollapse: suspend () -> Unit,
modifier: Modifier = Modifier,
) {
val degrees by animateFloatAsState(targetValue = if (expanded) 180f else 0f)
val coroutineScope = rememberCoroutineScope()
IconButton(
modifier = modifier,
onClick = { coroutineScope.launch { if (expanded) onCollapse() else onExpand() } },
) {
Icon(
painter = painterResource(R.drawable.keyboard_arrow_up_24px),
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.rotate(degrees),
)
}
}
@@ -0,0 +1,34 @@
package com.kouros.navigation.ui.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kouros.navigation.repository.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
class AppViewModel(
settingsRepository: SettingsRepository
) : ViewModel() {
val darkMode = settingsRepository.darkModeFlow
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
0
)
val show3D = settingsRepository.show3DFlow
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
)
val lastRoute = settingsRepository.lastRouteFlow
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
""
)
}
@@ -0,0 +1,29 @@
package com.kouros.navigation.ui.app
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kouros.navigation.data.datastore.DataStoreManager
import com.kouros.navigation.repository.SettingsRepository
@Composable
fun appViewModel(): AppViewModel {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
val repository = remember { SettingsRepository(dataStoreManager) }
return viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return AppViewModel(repository) as T
}
}
)
}
@@ -0,0 +1,59 @@
package com.kouros.navigation.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@Composable
fun RadioButtonSingleSelection(
modifier: Modifier = Modifier,
selectedOption: Int,
radioOptions: List<String>,
onClick: (Int) -> Unit,
) {
Column(modifier.selectableGroup()) {
for ((index, text) in radioOptions.withIndex()) {
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = (index == selectedOption),
onClick = {
onClick(index)
},
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (index == selectedOption),
onClick = null
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
@@ -0,0 +1,17 @@
package com.kouros.navigation.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp)
)
}
@@ -0,0 +1,29 @@
package com.kouros.navigation.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingItem(
title: String,
value: String? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = title)
value?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
}
}
}
@@ -0,0 +1,37 @@
package com.kouros.navigation.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun SettingSwitch(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = title)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White
)
)
}
}
@@ -0,0 +1,58 @@
package com.kouros.navigation.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.kouros.navigation.MainApplication.Companion.navigationViewModel
import com.kouros.navigation.ui.MainActivity
import com.kouros.navigation.ui.search.SearchScreen
import com.kouros.navigation.ui.settings.SettingsRoute
@Composable
fun AppNavGraph(mainActivity: MainActivity) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "startScreen") {
composable("startScreen") { mainActivity.StartScreen(navController) }
composable("display_settings") {
SettingsRoute(
"display_settings",
navController
) { navController.popBackStack() }
}
composable("nav_settings") {
SettingsRoute(
"nav_settings",
navController
) { navController.popBackStack() }
}
composable("settings") {
SettingsRoute(
"settings",
navController
) { navController.popBackStack() }
}
composable("search") {
SearchScreen(
navController,
navController.context,
navigationViewModel,
mainActivity.lastLocation
) { navController.popBackStack() }
}
composable("settings_screen") {
SettingsRoute(
"settings_screen",
navController
) { navController.popBackStack() }
}
composable("car_settings") {
SettingsRoute(
"car_settings",
navController
) { navController.popBackStack() }
}
}
}
@@ -0,0 +1,111 @@
package com.kouros.navigation.ui.navigation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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 = 1000
private val CardTopPadding = 60.dp
private val CardElevation = 6.dp
private val IconSize = 48.dp
private val ExitTextSize = 18.sp
private val PrimaryTextSize = 24.sp
private val SpacerWidth = 8.dp
private val CardPadding = 16.dp
private val ElementSpacing = 8.dp
@Composable
fun NavigationInfo(
step: StepData?,
nextStep: StepData?
) {
step?.takeIf { it.instruction.isNotEmpty() }?.let { currentStep ->
ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = CardElevation),
modifier = Modifier
.padding(top = CardTopPadding)
.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(CardPadding),
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = ElementSpacing)
) {
Icon(
painter = painterResource(currentStep.icon),
contentDescription = stringResource(id = R.string.navigation_icon_description),
modifier = Modifier.size(IconSize),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.padding(horizontal = SpacerWidth))
DistanceText(distance = currentStep.leftStepDistance)
}
if (currentStep.isExitManeuver) {
Text(
text = stringResource(R.string.exit_number, currentStep.exitNumber),
fontSize = ExitTextSize,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = ElementSpacing)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = ElementSpacing)
) {
Text(
text = currentStep.instruction,
fontSize = PrimaryTextSize,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@Composable
private fun DistanceText(distance: Double) {
val distancexx = formattedDistance(2, distance)
val formattedDistance = when {
distance < DISTANCE_THRESHOLD -> "${distance.toInt()} m"
else -> "${(distance / METERS_PER_KILOMETER).round(1)} km"
}
Text(
text = formattedDistance,
fontSize = PrimaryTextSize,
color = MaterialTheme.colorScheme.primary
)
}
private val StepData.isExitManeuver: Boolean
get() = currentManeuverType == MANEUVER_TYPE_EXIT_RIGHT ||
currentManeuverType == MANEUVER_TYPE_EXIT_LEFT
@@ -1,14 +1,15 @@
package com.kouros.navigation.ui.navigation
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -23,19 +24,22 @@ import com.kouros.navigation.utils.round
@Composable
fun NavigationSheet(
applicationContext: Context,
routeModel: RouteModel,
step: StepData,
nextStep: StepData,
nextStep: StepData?,
stopNavigation: () -> Unit,
simulateNavigation: () -> Unit,
) {
val distance = step.leftDistance.round(1)
val distance = (step.leftDistance / 1000).round(1)
Column {
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
Text(formatDateTime(step.arrivalTime), fontSize = 22.sp)
Spacer(Modifier.size(30.dp))
Text("$distance km", fontSize = 22.sp)
}
HorizontalDivider()
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
if (routeModel.isNavigating()) {
@@ -48,6 +52,15 @@ fun NavigationSheet(
modifier = Modifier.size(24.dp, 24.dp),
)
}
Button(onClick = {
simulateNavigation()
}) {
Icon(
painter = painterResource(id = R.drawable.ic_zoom_in_24),
"Stop",
modifier = Modifier.size(24.dp, 24.dp),
)
}
}
Spacer(Modifier.size(30.dp))
if (!routeModel.isNavigating()) {
@@ -0,0 +1,262 @@
package com.kouros.navigation.ui.search
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.kouros.data.R
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.PlaceColor
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.ui.app.AppViewModel
import com.kouros.navigation.ui.app.appViewModel
import com.kouros.navigation.ui.theme.NavigationTheme
import com.kouros.navigation.utils.location
import kotlin.math.roundToInt
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
navController: NavHostController,
context: Context,
navigationViewModel: NavigationViewModel,
location: Location,
function: () -> Unit
) {
val appViewModel: AppViewModel = appViewModel()
val darkMode by appViewModel.darkMode.collectAsState()
if (darkMode == 1 || darkMode == 2 && isSystemInDarkTheme()) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
}
//NavigationTheme(darkMode == 1 || darkMode == 2 && isSystemInDarkTheme()) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(id = R.string.search_action_title),
)
},
navigationIcon = {
IconButton(onClick = function) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
}
},
)
},
)
{ padding ->
val scrollState = rememberScrollState()
Column(Modifier.padding(top = 50.dp)) {
SearchBar(context, navigationViewModel, location)
Categories(context, navigationViewModel, location, closeSheet = { })
}
}
// }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
context: Context,
navigationViewModel: NavigationViewModel,
location: Location,
) {
val searchResults = mutableListOf<SearchResult>()
val search = navigationViewModel.searchPlaces.observeAsState()
if (search.value != null) {
searchResults.addAll(search.value!!)
}
val textFieldState = rememberTextFieldState()
var expanded by rememberSaveable { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
SearchBar(
inputField = {
SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.search_48px),
"Search",
modifier = Modifier.size(24.dp, 24.dp),
)
},
query = textFieldState.text.toString(),
onQueryChange = { textFieldState.edit { replace(0, length, it) } },
onSearch = {
navigationViewModel.searchPlaces(it, location)
expanded = true
},
expanded = expanded,
onExpandedChange = {
//expanded = it
},
placeholder = { Text(context.getString(R.string.search_action_title)) }
)
},
expanded = expanded,
onExpandedChange = { },
) {
if (searchResults.isNotEmpty()) {
SearchPlaces(searchResults, navigationViewModel, context, location, { })
}
}
}
@Composable
fun Categories(
applicationContext: Context,
viewModel: NavigationViewModel,
location: Location,
closeSheet: () -> Unit
) {
val scrollState = rememberScrollState()
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.horizontalScroll(scrollState)
) {
Button(onClick = {
val places = viewModel.loadRecentPlaces(applicationContext)
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, listOf(toLocation), 0F)
closeSheet()
}) {
Icon(
painter = painterResource(id = R.drawable.local_gas_station_24),
applicationContext.getString(R.string.fuel_station),
modifier = Modifier.size(24.dp, 24.dp),
)
}
Button(onClick = {
}) {
Icon(
painter = painterResource(id = R.drawable.ev_station_24px),
"Work",
modifier = Modifier.size(24.dp, 24.dp),
)
}
Button(onClick = {
}) {
Icon(
painter = painterResource(id = R.drawable.local_pharmacy_24px),
"Work",
modifier = Modifier.size(24.dp, 24.dp),
)
}
}
}
@Composable
private fun SearchPlaces(
searchResults: List<SearchResult>,
viewModel: NavigationViewModel,
context: Context,
location: Location,
closeSheet: () -> Unit
) {
val color = remember { PlaceColor }
LazyColumn(
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(searchResults, key = { it.placeId }) { place ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Navigation",
tint = color.copy(alpha = 1f),
modifier = Modifier.size(24.dp, 24.dp),
)
ListItem(
headlineContent = {Text(place.address.road)},
leadingContent = {Text(place.name)},
trailingContent = { Text("${(place.distance/1000).roundToInt()} km") },
modifier = Modifier
.animateItem()
.clickable {
val pl = Place(
name = place.name,
longitude = place.lon.toDouble(),
latitude = place.lat.toDouble(),
postalCode = place.address.postcode,
city = place.address.city,
street = place.address.road
)
viewModel.saveRecent(context, pl)
val toLocation =
location(place.lon.toDouble(), place.lat.toDouble())
viewModel.loadRoute(context, location, listOf(toLocation), 0F)
closeSheet()
}
.fillMaxWidth()
)
HorizontalDivider(color = Color.Gray)
}
}
}
}
@@ -0,0 +1,180 @@
package com.kouros.navigation.ui.search
import android.content.Context
import android.location.Location
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.kouros.data.R
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.PlaceColor
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.location
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchSheet(
applicationContext: Context,
navController: NavHostController,
viewModel: NavigationViewModel,
location: Location,
closeSheet: () -> Unit
) {
val recentPlaces = viewModel.recentPlaces.observeAsState()
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
SearchBar(
modifier = Modifier.clickable {
navController.navigate("search")
},
colors = SearchBarDefaults.colors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
expanded = false,
onExpandedChange = { navController.navigate("search") },
inputField = {
SearchBarDefaults.InputField(
enabled = false,
modifier = Modifier.clickable {
navController.navigate("search")
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.search_48px),
applicationContext.getString(R.string.search_action_title),
modifier = Modifier.size(24.dp, 24.dp),
)
},
query = applicationContext.getString(R.string.search_action_title),
onQueryChange = { },
onSearch = {
},
expanded = false,
onExpandedChange = { },
placeholder = { applicationContext.getString(R.string.search_action_title) }
)
},
) {
}
Home(applicationContext, viewModel, location, closeSheet = { closeSheet() })
if (recentPlaces.value != null) {
val items = listOf(recentPlaces)
if (items.isNotEmpty()) {
Column(Modifier.padding(all = 10.dp)) {
Text(applicationContext.getString(R.string.recent_destinations))
RecentPlaces(
recentPlaces.value!!,
viewModel,
applicationContext,
location,
closeSheet
)
}
}
}
}
}
@Composable
fun Home(
applicationContext: Context,
viewModel: NavigationViewModel,
location: Location,
closeSheet: () -> Unit
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = {
val places = viewModel.loadRecentPlaces(applicationContext)
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, listOf(toLocation), 0F)
closeSheet()
}) {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Home",
modifier = Modifier.size(24.dp, 24.dp),
)
Text("Home")
}
Button(onClick = {
}) {
Icon(
painter = painterResource(id = R.drawable.ic_favorite_white_24dp),
"Work",
modifier = Modifier.size(24.dp, 24.dp),
)
Text("Arbeit")
}
}
}
@Composable
private fun RecentPlaces(
recentPlaces: List<Place>,
viewModel: NavigationViewModel,
context: Context,
location: Location,
closeSheet: () -> Unit
) {
val color = remember { PlaceColor }
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(recentPlaces, key = { it.id }) { place ->
Row {
Icon(
painter = painterResource(id = R.drawable.ic_place_white_24dp),
"Navigation",
//tint = color.copy(alpha = 1f),
modifier = Modifier.size(24.dp, 24.dp),
)
ListItem(
headlineContent = { Text("${place.street} ${place.postalCode} ${place.city}") },
modifier = Modifier
.clickable {
val toLocation = location(place.longitude, place.latitude)
viewModel.loadRoute(context, location, listOf(toLocation), 0F)
closeSheet()
}
.fillMaxWidth()
)
HorizontalDivider(color = Color.Gray)
}
}
}
}
@@ -0,0 +1,90 @@
package com.kouros.navigation.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kouros.data.R
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.ui.components.RadioButtonSingleSelection
import com.kouros.navigation.ui.components.SectionTitle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CarScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
val engineType by viewModel.engineType.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(id = R.string.car_settings),
)
},
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
}
},
)
},
)
{ paddingValues ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(top = 10.dp)
.verticalScroll(scrollState)
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SectionTitle(stringResource(R.string.engine_type))
val radioOptions = listOf(
stringResource(R.string.combustion),
stringResource(R.string.electric),
)
RadioButtonSingleSelection(
modifier = Modifier.padding(),
selectedOption = engineType,
radioOptions = radioOptions,
onClick = viewModel::onEngineTypeChanged
)
}
}
}
}
}
@@ -0,0 +1,142 @@
package com.kouros.navigation.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kouros.data.R
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.ui.components.RadioButtonSingleSelection
import com.kouros.navigation.ui.components.SectionTitle
import com.kouros.navigation.ui.components.SettingSwitch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisplayScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
val darkMode by viewModel.darkMode.collectAsState()
val show3D by viewModel.show3D.collectAsState()
val showTraffic by viewModel.traffic.collectAsState()
val distanceMode by viewModel.distanceMode.collectAsState()
val driveSuggestion by viewModel.tripSuggestion.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(id = R.string.display_settings),
)
},
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
}
},
)
},
)
{ paddingValues ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(top = 10.dp)
.verticalScroll(scrollState)
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SettingSwitch(
title = stringResource(R.string.threed_building),
checked = show3D,
onCheckedChange = viewModel::onShow3DChanged
)
SettingSwitch(
title = stringResource(R.string.traffic),
checked = showTraffic,
onCheckedChange = viewModel::onTraffic
)
SettingSwitch(
title = stringResource(R.string.trip_suggestion),
checked = driveSuggestion,
onCheckedChange = viewModel::onTripSuggestion
)
}
}
Spacer(modifier = Modifier.height(20.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SectionTitle(stringResource(R.string.dark_mode))
val radioOptions = listOf(
stringResource(R.string.off_action_title),
stringResource(R.string.on_action_title),
stringResource(R.string.use_telephon_settings)
)
RadioButtonSingleSelection(
modifier = Modifier.padding(),
selectedOption = darkMode,
radioOptions = radioOptions,
onClick = viewModel::onDarkModeChanged
)
}
}
Spacer(modifier = Modifier.height(20.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SectionTitle(stringResource(R.string.distance_units))
val radioOptions = listOf(
stringResource(R.string.automatically),
stringResource(R.string.kilometer),
stringResource(R.string.miles)
)
RadioButtonSingleSelection(
modifier = Modifier.padding(),
selectedOption = distanceMode,
radioOptions = radioOptions,
onClick = viewModel::onDistanceModeChanged
)
}
}
}
}
}
@@ -0,0 +1,145 @@
package com.kouros.navigation.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.kouros.data.R
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.ui.components.RadioButtonSingleSelection
import com.kouros.navigation.ui.components.SectionTitle
import com.kouros.navigation.ui.components.SettingSwitch
import com.kouros.navigation.ui.theme.NavigationTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NavigationScreen(viewModel: SettingsViewModel, navigateBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(id = R.string.navigation_settings),
)
},
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.size(48.dp, 48.dp),
)
}
},
)
},
) { padding ->
val scrollState = rememberScrollState()
Column(
modifier =
Modifier
.padding(padding)
.verticalScroll(scrollState)
) {
NavigationSettings(viewModel)
}
}
}
@Composable
fun NavigationSettings(viewModel: SettingsViewModel) {
val avoidMotorway by viewModel.avoidMotorway.collectAsState()
val avoidTollway by viewModel.avoidTollway.collectAsState()
val carLocation by viewModel.carLocation.collectAsState()
val routingEngine by viewModel.routingEngine.collectAsState()
val tomTomApiKey by viewModel.tomTomApiKey.collectAsState()
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SettingSwitch(
title = stringResource(R.string.avoid_highways_row_title),
checked = avoidMotorway,
onCheckedChange = viewModel::onAvoidMotorway
)
SettingSwitch(
title = stringResource(R.string.avoid_tolls_row_title),
checked = avoidTollway,
onCheckedChange = viewModel::onAvoidTollway
)
SettingSwitch(
title = stringResource(R.string.use_car_location),
checked = carLocation,
onCheckedChange = viewModel::onCarLocation
)
}
}
Spacer(modifier = Modifier.height(20.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(10.dp),
) {
SectionTitle(stringResource(R.string.routing_engine))
val routingEngineOptions = listOf(
stringResource(R.string.valhalla),
stringResource(R.string.osrm),
stringResource(R.string.tomtom)
)
RadioButtonSingleSelection(
modifier = Modifier.padding(),
selectedOption = routingEngine,
radioOptions = routingEngineOptions,
onClick = viewModel::onRoutingEngineChanged
)
if (routingEngine == RouteEngine.TOMTOM.ordinal) {
var key by remember { mutableStateOf(tomTomApiKey) }
TextField(
value = key,
onValueChange = {
viewModel.onTomTomApiKeyChanged(it)
key = it
},
label = { Text(stringResource(R.string.tomtom_api_key)) },
textStyle = TextStyle(color = Color.Green, fontWeight = FontWeight.Bold),
modifier = Modifier.padding(20.dp)
)
}
}
}
}
@@ -0,0 +1,120 @@
package com.kouros.navigation.ui.settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.kouros.data.R
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.ui.theme.NavigationTheme
data class Settings(val id: String, val name: String, val icon: Int)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Settings(
viewModel: SettingsViewModel,
navController: NavHostController,
navigateBack: () -> Unit
) {
val items = listOf(
Settings(
id = "favorites_screen",
name = "Favoriten",
icon = R.drawable.ic_favorite_white_24dp
),
Settings(
id = "settings_screen",
name = "Einstellungen",
icon = R.drawable.speed_camera_24px
),
Settings(id = "info_screen", name = "Info", icon = R.drawable.ic_place_white_24dp),
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(id = R.string.settings_action_title)) },
)
},
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.padding(8.dp),
) {
item {
Spacer(modifier = Modifier.height(10.dp))
}
items(items) { subItem ->
ScreenItem(item = subItem, onClick = { navController.navigate(subItem.id) })
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@Composable
fun ScreenItem(
item: Settings,
onClick: (Settings) -> Unit,
) {
OutlinedCard(onClick = { onClick(item) }, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(item.icon),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.align(Alignment.CenterVertically),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = item.name, style = MaterialTheme.typography.titleMedium)
}
IconForward()
}
}
}
@Composable
fun RowScope.IconForward() {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = stringResource(id = R.string.on_action_title),
modifier = Modifier.align(Alignment.CenterVertically),
)
}
@@ -0,0 +1,45 @@
package com.kouros.navigation.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.kouros.navigation.data.datastore.DataStoreManager
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
@Composable
fun SettingsRoute(route: String, navController: NavHostController, function: () -> Unit) {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
val repository = remember { SettingsRepository(dataStoreManager) }
val viewModel: SettingsViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return SettingsViewModel(repository) as T
}
}
)
if (route == "display_settings") {
DisplayScreen(viewModel = viewModel, function)
}
if (route == "nav_settings") {
NavigationScreen (viewModel = viewModel, function)
}
if (route == "settings") {
Settings(viewModel, navController, function)
}
if (route == "settings_screen") {
SettingsScreen(viewModel, navController, function)
}
if (route == "car_settings") {
CarScreen (viewModel = viewModel, function)
}
}
@@ -0,0 +1,113 @@
package com.kouros.navigation.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.kouros.data.R
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.ui.theme.NavigationTheme
data class Item(val id: String, val name: String, val description: String, val icon: Int)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
navController: NavHostController,
navigateBack: () -> Unit
) {
val items = listOf(
Item(
id = "display_settings",
name = "Display Settings",
description = "",
icon = R.drawable.dark_mode_24px
),
Item(
id = "nav_settings",
name = "Navigation Settings",
description = "",
icon = R.drawable.navigation_24px
),
Item(
id = "car_settings",
name = "Car Settings",
description = "",
icon = R.drawable.electric_car_24px
)
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
)
},
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.padding(8.dp),
) {
item {
Spacer(modifier = Modifier.height(10.dp))
}
items(items) { subItem ->
ScreenItem(item = subItem, onClick = { navController.navigate(subItem.id) })
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@Composable
fun ScreenItem(
item: Item,
onClick: (Item) -> Unit,
) {
OutlinedCard(onClick = { onClick(item) }, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(item.icon),
contentDescription = stringResource(id = R.string.accept_action_title),
modifier = Modifier.align(Alignment.CenterVertically),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = item.name, style = MaterialTheme.typography.titleMedium)
}
IconForward()
}
}
}
@@ -2,10 +2,67 @@ package com.kouros.navigation.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val md_theme_light_primary = Color(0xFF6FE5E1)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDDB3)
val md_theme_light_onPrimaryContainer = Color(0xFF291800)
val md_theme_light_secondary = Color(0xFF6F5B40)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFBDEBC)
val md_theme_light_onSecondaryContainer = Color(0xFF271904)
val md_theme_light_tertiary = Color(0xFF51643F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFD4EABB)
val md_theme_light_onTertiaryContainer = Color(0xFF102004)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1F1B16)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1F1B16)
val md_theme_light_surfaceVariant = Color(0xFFF0E0CF)
val md_theme_light_onSurfaceVariant = Color(0xFF4F4539)
val md_theme_light_outline = Color(0xFF817567)
val md_theme_light_inverseOnSurface = Color(0xFFF9EFE7)
val md_theme_light_inverseSurface = Color(0xFF34302A)
val md_theme_light_inversePrimary = Color(0xFFFFB951)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF825500)
val md_theme_light_outlineVariant = Color(0xFFD3C4B4)
val md_theme_light_scrim = Color(0xFF000000)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val md_theme_dark_primary = Color(0xFF8CD6EF)
val md_theme_dark_onPrimary = Color(0xFF452B00)
val md_theme_dark_primaryContainer = Color(0xFF633F00)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDDB3)
val md_theme_dark_secondary = Color(0xFFDDC2A1)
val md_theme_dark_onSecondary = Color(0xFF3E2D16)
val md_theme_dark_secondaryContainer = Color(0xFF56442A)
val md_theme_dark_onSecondaryContainer = Color(0xFFFBDEBC)
val md_theme_dark_tertiary = Color(0xFFB8CEA1)
val md_theme_dark_onTertiary = Color(0xFF243515)
val md_theme_dark_tertiaryContainer = Color(0xFF3A4C2A)
val md_theme_dark_onTertiaryContainer = Color(0xFFD4EABB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1F1B16)
val md_theme_dark_onBackground = Color(0xFFEAE1D9)
val md_theme_dark_surface = Color(0xFF1F1B16)
val md_theme_dark_onSurface = Color(0xFFEAE1D9)
val md_theme_dark_surfaceVariant = Color(0xFF4F4539)
val md_theme_dark_onSurfaceVariant = Color(0xFFD3C4B4)
val md_theme_dark_outline = Color(0xFF9C8F80)
val md_theme_dark_inverseOnSurface = Color(0xFF1F1B16)
val md_theme_dark_inverseSurface = Color(0xFFEAE1D9)
val md_theme_dark_inversePrimary = Color(0xFF825500)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB951)
val md_theme_dark_outlineVariant = Color(0xFF4F4539)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF825500)
@@ -0,0 +1,29 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val shapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp)
)
@@ -1,57 +1,107 @@
package com.kouros.navigation.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun NavigationTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
useDarkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
useDarkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
typography = typography,
content = content,
shapes = shapes,
)
}
@@ -7,28 +7,35 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
val typography = Typography(
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
letterSpacing = 0.15.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
bodyMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -3,7 +3,7 @@
android:height="108dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#000000">
android:tint="#1A7416">
<group android:scaleX="0.7888"
android:scaleY="0.7888"
android:translateX="101.376"
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#98DABB</color>
</resources>
+2 -9
View File
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
android {
@@ -31,19 +30,13 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
dependencies {
implementation(libs.androidx.app.automotive)
implementation(libs.androidx.car.app)
implementation(libs.androidx.material3)
implementation(libs.androidx.runtime.livedata)
implementation(project(":common:car"))
@@ -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)
}
}
+7 -2
View File
@@ -8,9 +8,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.car.permission.CAR_SPEED"/>
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Various required feature settings for an automotive app. -->
<uses-feature
android:name="android.hardware.type.automotive"
android:required="true" />
@@ -70,7 +71,11 @@
android:name="distractionOptimized"
android:value="true" />
</activity>
<service
android:name=".car.NavigationNotificationService"
android:foregroundServiceType="location"
android:exported="true">
</service>
</application>
</manifest>
@@ -1,170 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -3,11 +3,11 @@
android:height="108dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#000000">
<group android:scaleX="0.7888"
android:scaleY="0.7888"
android:translateX="101.376"
android:translateY="101.376">
android:tint="#1A7416">
<group android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="201.6"
android:translateY="201.6">
<path
android:fillColor="@android:color/white"
android:pathData="M319,680L480,607L641,680L656,665L480,240L304,665L319,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -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)
}
}
+1 -4
View File
@@ -2,18 +2,15 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false
}
buildscript {
val objectboxVersion by extra("5.0.1") // For KTS build scripts
dependencies {
// Android Gradle Plugin 8.0 or later supported
classpath(libs.gradle)
classpath("io.objectbox:objectbox-gradle-plugin:$objectboxVersion")
}
}
+21 -11
View File
@@ -1,8 +1,6 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
@@ -26,19 +24,17 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
}
}
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.car.app)
@@ -46,8 +42,6 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.ui)
implementation(libs.maplibre.compose)
//implementation(libs.maplibre.composeMaterial3)
implementation(project(":common:data"))
implementation(libs.androidx.runtime.livedata)
implementation(libs.androidx.compose.foundation)
@@ -55,6 +49,22 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.ui.text)
implementation(libs.play.services.location)
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.monitor)
implementation(libs.android.gpx.parser)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.androidx.car.app.testing)
testImplementation(libs.robolectric)
testImplementation(libs.google.truth)
testImplementation(libs.androidx.test.core)
mockitoAgent(libs.mockito.core) { isTransitive = false }
}
@@ -0,0 +1,44 @@
package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kouros.navigation.utils.GeoUtils
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.maplibre.geojson.Point
@RunWith(AndroidJUnit4::class)
class GeoUtilsTest {
@Test
fun snapLocation() {
val location = Location(LocationManager.GPS_PROVIDER)
location.latitude = 48.18600
location.longitude = 11.57844
val stepCoordinates = listOf(
Point.fromLngLat(11.57841, 48.18557),
Point.fromLngLat(11.57844, 48.18566),
Point.fromLngLat(11.57848, 48.18595),
Point.fromLngLat(11.57848, 48.18604),
Point.fromLngLat(11.57857, 48.18696),
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(result.latitude, 48.18599999996868, 0.0001)
}
@Test
fun `createCenterLocation calculates center of GeoJSON`() {
val geoJson =
"""{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[11.0,48.0]},"properties":{}},{"type":"Feature","geometry":{"type":"Point","coordinates":[11.1,48.1]},"properties":{}}]}"""
val result = GeoUtils.createCenterLocation(geoJson)
// Center should be roughly halfway between the two points
assertEquals(48.05, result.latitude, 0.01)
assertEquals(11.05, result.longitude, 0.01)
}
}
@@ -0,0 +1,255 @@
package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kouros.navigation.data.Constants.homeHohenwaldeck
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.route.ManeuverType
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Before
import kotlin.collections.forEach
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class RouteModelTest {
val routeModel = RouteModel()
val location = Location(LocationManager.GPS_PROVIDER)
val distance = listOf(
1025.5,
989.8,
963.5,
923.7,
915.8,
914.6,
871.0,
822.7,
769.7,
713.8,
644.8,
577.6,
501.7,
489.7,
452.5,
437.4,
398.0,
390.1,
341.3,
266.6,
219.5,
140.7,
77.4,
55.1,
40.0,
30.0,
19.0,
4.0
)
@Before
fun setup() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val repository = getSettingsRepository(appContext)
runBlocking { repository.setRoutingEngine(RouteEngine.TOMTOM.ordinal) }
val routeJsonString = TomTomRepository().fetchUrl(
"https://kouros-online.de/tomtom_routing.json",
false
)
assertNotEquals("", routeJsonString)
routeModel.navState = routeModel.navState.copy(routingEngine = RouteEngine.TOMTOM.ordinal)
routeModel.startNavigation(routeJsonString)
}
@Test
fun checkRoute() {
assertEquals(true, routeModel.isNavigating())
assertEquals(routeModel.curRoute.summary.distance, 11108.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1094.0, 10.0)
}
@Test
fun checkDeparture() {
location.latitude = 48.185569
location.longitude = 11.579034
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(stepData.instruction, "Silcherstraße")
assertEquals(stepData.leftStepDistance, 20.0, 5.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(nextStepData.instruction, "Schmalkaldener Straße")
}
@Test
fun checkSchmalkadener20() {
location.latitude = 48.187057
location.longitude = 11.576652
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(stepData.instruction, "Schmalkaldener Straße")
assertEquals(stepData.leftStepDistance, 0.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(nextStepData.instruction, "Ingolstädter Straße")
}
@Test
fun checkIngol() {
location.latitude = 48.180555
location.longitude = 11.585125
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
if (routeModel.navState.nextStep) {
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_STRAIGHT.value)
assertEquals(stepData.instruction, "Ingolstädter Straße")
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
} else {
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
}
assertEquals(stepData.leftStepDistance, 301.0, 1.0)
}
@Test
fun checkIngol2() {
location.latitude = 48.179286
location.longitude = 11.585258
location.bearing = 180.0F
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
assertEquals(stepData.instruction, "Schenkendorfstraße")
assertEquals(stepData.leftStepDistance, 170.0, 10.0)
assertEquals(stepData.lane.size, 4)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.last().valid, false)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_KEEP_LEFT.value)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
}
@Test
fun checkDestination() {
location.latitude = homeHohenwaldeck.latitude
location.longitude = homeHohenwaldeck.longitude
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.nextStep()
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_DESTINATION_LEFT.value)
}
@Test
fun checkLanes() {
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 42..45) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 4)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.first().indications.first(), "SLIGHT_LEFT")
}
if (index in 61..61) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 2)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.first().indications.first(), "STRAIGHT")
}
if (index in 74..75) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 3)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.first().indications.first(), "SLIGHT_LEFT")
assertEquals(stepData.lane[1].valid, true)
assertEquals(stepData.lane[1].indications.first(), "SLIGHT_LEFT")
}
if (index in 265..265) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 2)
assertEquals(stepData.lane.first().valid, false)
assertEquals(stepData.lane.first().indications.first(), "STRAIGHT")
assertEquals(stepData.lane[1].valid, true)
assertEquals(stepData.lane[1].indications.first(), "SLIGHT_RIGHT")
}
}
}
}
@Test
fun simulate() {
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
if (routeModel.isNavigating()) {
val curLocation = location(waypoint[0], waypoint[1])
if (index in 0..routeModel.curRoute.waypoints.size) {
//runBlocking { delay(1000) }
val start = System.currentTimeMillis()
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
//println("${stepData.instruction} ${System.currentTimeMillis() - start}")
if (stepData.lane.isNotEmpty()) {
// println(stepData.street)
stepData.lane.forEach {
// println("${it.indications} ${it.valid}")
}
}
// val nextData = routeModel.nextStep()
}
}
}
}
@Test
fun `leftStepDistance Inglolstädter `() {
val location: Location = location(11.584578, 48.183653)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 645.0, 1.0)
}
@Test
fun `leftStepDistance Vogelhart `() {
val location: Location = location(11.578911, 48.185565)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 26.0, 1.0)
}
@Test
fun leftStepDistance() {
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 16..43) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.leftStepDistance, distance[index-16], 1.0)
}
}
}
}
}
+5 -1
View File
@@ -32,8 +32,11 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED"/>
<uses-permission android:name="android.car.permission.READ_CAR_DISPLAY_UNITS"/>
<application android:requestLegacyExternalStorage="true">
<application android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
@@ -46,6 +49,7 @@
<category android:name="androidx.car.app.category.WEATHER" />
<category android:name="androidx.car.app.category.POI"/>
<category android:name="androidx.car.app.category.NAVIGATION"/>
<category android:name="androidx.car.app.category.FEATURE_CLUSTER"/>
</intent-filter>
</service>
</application>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

@@ -0,0 +1,192 @@
package com.kouros.navigation.car
import android.location.Location
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.connection.CarConnection
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.Speed
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.utils.getSettingsRepository
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Manages car hardware sensor listeners for navigation.
* Handles location, compass, and speed sensors from the car hardware.
*
* @param carContext The car context for accessing hardware services
* @param lifecycleOwner Owner of the lifecycle for coroutine management
* @param onLocationUpdate Callback for location updates
* @param onCompassUpdate Callback for compass/orientation updates
* @param onSpeedUpdate Callback for speed updates
*/
class CarSensorManager(
private val carContext: CarContext,
private val lifecycleOwner: LifecycleOwner,
private val onLocationUpdate: (Location) -> Unit,
private val onCompassUpdate: (Float) -> Unit,
private val onSpeedUpdate: (Float) -> Unit
) {
private val carHardwareManager: CarHardwareManager =
carContext.getCarService(CarHardwareManager::class.java)
private val settingsRepository = getSettingsRepository(carContext)
private var carConnection: Int = CarConnection.CONNECTION_TYPE_NOT_CONNECTED
private var isLocationSensorActive = false
private var isSpeedSensorActive = false
/**
* Car hardware location listener.
* Receives location data from the car's GPS system.
*/
private val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
OnCarDataAvailableListener { data ->
if (data.location.status == CarValue.STATUS_SUCCESS) {
val location = data.location.value
if (location != null) {
onLocationUpdate(location)
}
}
}
/**
* Car compass/orientation sensor listener.
* Updates orientation for map rotation.
*/
private val carCompassListener: OnCarDataAvailableListener<Compass?> =
OnCarDataAvailableListener { data ->
if (data.orientations.status == CarValue.STATUS_SUCCESS) {
val orientation = data.orientations.value
if (!orientation.isNullOrEmpty()) {
onCompassUpdate(orientation[0])
}
}
}
/**
* Car speed sensor listener.
* Receives speed in meters per second from car hardware.
*/
private val carSpeedListener = OnCarDataAvailableListener<Speed> { data ->
if (data.displaySpeedMetersPerSecond.status == CarValue.STATUS_SUCCESS) {
val speed = data.displaySpeedMetersPerSecond.value
if (speed != null) {
onSpeedUpdate(speed)
}
}
}
init {
// Observe car location setting changes
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
settingsRepository.carLocationFlow.collectLatest { useCarLocation ->
if (useCarLocation) {
addLocationSensors()
} else {
removeLocationSensors()
}
}
}
}
}
/**
* Updates the car connection state and manages speed sensor accordingly.
*
* @param connectionState The current car connection type
*/
fun updateConnectionState(connectionState: Int) {
carConnection = connectionState
when (connectionState) {
CarConnection.CONNECTION_TYPE_NATIVE,
CarConnection.CONNECTION_TYPE_PROJECTION -> addSpeedSensor()
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> removeSpeedSensor()
}
}
/**
* Checks if car location sensors should be used based on settings.
*
* @return Flow of boolean indicating if car location should be used
*/
fun shouldUseCarLocation() = settingsRepository.carLocationFlow
/**
* Adds location and compass sensors if not already active.
*/
private fun addLocationSensors() {
if (isLocationSensorActive) return
val carSensors = carHardwareManager.carSensors
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
carContext.mainExecutor,
carCompassListener
)
carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_UI,
carContext.mainExecutor,
carLocationListener
)
isLocationSensorActive = true
}
/**
* Removes location and compass sensors.
*/
private fun removeLocationSensors() {
if (!isLocationSensorActive) return
val carSensors = carHardwareManager.carSensors
carSensors.removeCarHardwareLocationListener(carLocationListener)
carSensors.removeCompassListener(carCompassListener)
isLocationSensorActive = false
}
/**
* Adds speed sensor if not already active.
*/
private fun addSpeedSensor() {
if (isSpeedSensorActive) return
if (carConnection == CarConnection.CONNECTION_TYPE_NATIVE ||
carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
val carInfo = carHardwareManager.carInfo
carInfo.addSpeedListener(carContext.mainExecutor, carSpeedListener)
isSpeedSensorActive = true
}
}
/**
* Removes speed sensor.
*/
private fun removeSpeedSensor() {
if (!isSpeedSensorActive) return
val carInfo = carHardwareManager.carInfo
carInfo.removeSpeedListener(carSpeedListener)
isSpeedSensorActive = false
}
/**
* Cleans up all sensor listeners.
* Should be called when the session is destroyed.
*/
fun cleanup() {
removeLocationSensors()
removeSpeedSensor()
}
}
@@ -0,0 +1,10 @@
package com.kouros.navigation.car
import androidx.car.app.Session
import kotlinx.coroutines.flow.Flow
abstract class CarSession : Session() {
abstract fun invalidateNavigationScreen()
}
@@ -0,0 +1,185 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car
import android.content.Intent
import android.content.res.Configuration
import android.location.Location
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.OnClickListener
import androidx.car.app.navigation.model.Trip
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationListener
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.datastore.DataStoreManager.PreferencesKeys.CAR_LOCATION
import com.kouros.navigation.data.datastore.dataStore
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/** Session class for the Navigation sample app. */
internal class ClusterSession : CarSession(), NavigationListener {
lateinit var mNavigationScreen: NavigationScreen
lateinit var surfaceRenderer: SurfaceRenderer
var routeModel = RouteCarModel()
lateinit var viewModelStoreOwner: ViewModelStoreOwner
lateinit var navigationViewModel: NavigationViewModel
lateinit var deviceLocationManager: DeviceLocationManager
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d(Constants.TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(Constants.TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onDestroy(owner: LifecycleOwner) {
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
}
}
init {
lifecycle.addObserver(lifecycleObserver)
}
override fun onCreateScreen(intent: Intent): Screen {
Log.i(TAG, "In onCreateScreen()")
setupViewModelStore()
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner, this)
navigationViewModel = NavigationViewModel(TomTomRepository())
mNavigationScreen =
NavigationScreen(carContext, surfaceRenderer, this, navigationViewModel)
val action = intent.action
if (CarContext.ACTION_NAVIGATE == action) {
Log.i(TAG, "In onCreateScreen() Navigation intent")
CarToast.makeText(
carContext,
"Navigation intent: " + intent.dataString,
CarToast.LENGTH_LONG
)
.show()
}
initializeManagers()
return mNavigationScreen
}
/**
* Initializes managers for rendering, sensors, and location.
*/
private fun initializeManagers() {
deviceLocationManager = DeviceLocationManager(
carContext = carContext,
lifecycleOwner = this,
shouldUseCarLocationFlow = flowOf(false),
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
})
deviceLocationManager.startLocationUpdates()
}
fun updateLocation(location: Location) {
Log.d(TAG, "updateLocation $location")
surfaceRenderer.updateLocation(location, "")
}
override fun onCarConfigurationChanged(newConfiguration: Configuration) {
// mNavigationCarSurface.onCarConfigurationChanged();
}
override fun stopNavigation() {
}
override fun startNavigation() {
}
override fun updateTrip(trip: Trip) {
}
override fun navigateToPlace(place: Place) {
}
override fun recalcRoute(destination: Place) {
}
companion object {
val TAG: String = ClusterSession::class.java.getSimpleName()
}
private fun setupViewModelStore() {
viewModelStoreOwner = object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
lifecycleScope.launch {
try {
awaitCancellation()
} finally {
viewModelStoreOwner.viewModelStore.clear()
}
}
}
override fun invalidateNavigationScreen() {
mNavigationScreen.invalidate()
}
}
@@ -0,0 +1,105 @@
package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import androidx.car.app.CarContext
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Manages device GPS location updates for navigation.
* Coordinates with car hardware sensors to avoid duplicate location sources.
*
* @param carContext The car context for accessing system services
* @param lifecycleOwner Owner of the lifecycle for coroutine management
* @param shouldUseCarLocationFlow Flow indicating whether car location hardware should be used
* @param onLocationUpdate Callback invoked when location updates are received
* @param onInitialLocation Callback invoked with the last known location when starting
*/
class DeviceLocationManager(
private val carContext: CarContext,
private val lifecycleOwner: LifecycleOwner,
private val shouldUseCarLocationFlow: Flow<Boolean>,
private val onLocationUpdate: (Location) -> Unit,
private val onInitialLocation: (Location) -> Unit
) {
private val locationManager: LocationManager =
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var shouldUseDeviceLocation = true
private var isListening = false
/**
* Location listener that receives GPS updates from the device.
* Only processes location if car location hardware is not being used.
*/
private val locationListener: LocationListenerCompat = LocationListenerCompat { location ->
if (shouldUseDeviceLocation) {
onLocationUpdate(location)
}
}
init {
// Observe car location setting to toggle device location usage
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
shouldUseCarLocationFlow.collectLatest { useCarLocation ->
shouldUseDeviceLocation = !useCarLocation
}
}
}
}
/**
* Starts requesting location updates from device GPS.
* Provides initial location via callback and then starts continuous updates.
*
* @param minTimeMs Minimum time interval between updates in milliseconds (default: 500ms)
* @param minDistanceM Minimum distance between updates in meters (default: 5m)
*/
@SuppressLint("MissingPermission")
fun startLocationUpdates(minTimeMs: Long = 1000, minDistanceM: Float = 5f) {
if (isListening) return
// Get and deliver last known location first
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (lastLocation != null) {
onInitialLocation(lastLocation)
onLocationUpdate(lastLocation)
}
// Start continuous location updates
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
minTimeMs,
minDistanceM,
locationListener
)
isListening = true
}
/**
* Stops receiving location updates from device GPS.
* Should be called when the session is destroyed to prevent memory leaks.
*/
fun stopLocationUpdates() {
if (!isListening) return
locationManager.removeUpdates(locationListener)
isListening = false
}
/**
* Checks if location updates are currently active.
*/
fun isListeningForUpdates(): Boolean = isListening
}
@@ -0,0 +1,97 @@
package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import androidx.car.app.CarContext
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.navigation.car.navigation.NavigationService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Manages device GPS location updates for navigation.
* Coordinates with car hardware sensors to avoid duplicate location sources.
*
* @param carContext The car context for accessing system services
* @param serviceOwner Owner of the lifecycle for coroutine management
* @param shouldUseCarLocationFlow Flow indicating whether car location hardware should be used
* @param onLocationUpdate Callback invoked when location updates are received
* @param onInitialLocation Callback invoked with the last known location when starting
*/
class DeviceLocationManagerService(
private val carContext: CarContext,
private val onLocationUpdate: (Location) -> Unit,
private val onInitialLocation: (Location) -> Unit
) {
private val locationManager: LocationManager =
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var shouldUseDeviceLocation = true
private var isListening = false
/**
* Location listener that receives GPS updates from the device.
* Only processes location if car location hardware is not being used.
*/
private val locationListener: LocationListenerCompat = LocationListenerCompat { location ->
if (shouldUseDeviceLocation) {
onLocationUpdate(location)
}
}
init {
}
/**
* Starts requesting location updates from device GPS.
* Provides initial location via callback and then starts continuous updates.
*
* @param minTimeMs Minimum time interval between updates in milliseconds (default: 500ms)
* @param minDistanceM Minimum distance between updates in meters (default: 5m)
*/
@SuppressLint("MissingPermission")
fun startLocationUpdates(minTimeMs: Long = 1000, minDistanceM: Float = 5.0f) {
if (isListening) return
// Get and deliver last known location first
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (lastLocation != null) {
onInitialLocation(lastLocation)
onLocationUpdate(lastLocation)
}
// Start continuous location updates
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
minTimeMs,
minDistanceM,
locationListener
)
isListening = true
}
/**
* Stops receiving location updates from device GPS.
* Should be called when the session is destroyed to prevent memory leaks.
*/
fun stopLocationUpdates() {
if (!isListening) return
locationManager.removeUpdates(locationListener)
isListening = false
}
/**
* Checks if location updates are currently active.
*/
fun isListeningForUpdates(): Boolean = isListening
}
@@ -1,31 +1,28 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.location.Location
import android.app.NotificationChannel
import android.app.NotificationManager
import android.net.Uri
import android.util.Log
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.SessionInfo
import androidx.car.app.validation.HostValidator
import com.kouros.navigation.data.Constants.TAG
class NavigationCarAppService : CarAppService() {
val intentActionNavNotificationOpenApp =
"com.kouros.navigation.INTENT_ACTION_NAV_NOTIFICATION_OPEN_APP"
val channelId: String = "NavigationSessionChannel"
fun createDeepLinkUri(deepLinkAction: String): Uri {
return Uri.fromParts(NavigationSession.uriScheme, NavigationSession.uriHost, deepLinkAction)
}
@SuppressLint("PrivateResource")
override fun createHostValidator(): HostValidator {
@@ -34,13 +31,27 @@ class NavigationCarAppService : CarAppService() {
}
override fun onCreateSession(sessionInfo: SessionInfo): Session {
return NavigationSession()
if (sessionInfo.displayType == SessionInfo.DISPLAY_TYPE_CLUSTER) {
return ClusterSession()
} else {
createNotificationChannel()
//return NavigationSession()
return NavigationServiceSession()
}
}
private fun createNotificationChannel() {
val notificationManager =
getSystemService(NotificationManager::class.java)
val name: CharSequence = "Car App Service"
val serviceChannel =
NotificationChannel(
channelId,
name,
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(serviceChannel)
}
}
public interface LocationCallback {
fun onLocationChanged(location: Location) {
}
}
@@ -0,0 +1,260 @@
package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.util.Log
import androidx.car.app.notification.CarAppExtender
import androidx.car.app.notification.CarNotificationManager
import androidx.car.app.notification.CarPendingIntent
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.kouros.data.R
import com.kouros.navigation.data.Constants.TAG
import java.math.RoundingMode
import java.text.DecimalFormat
import java.util.concurrent.TimeUnit
/**
* A simple foreground service that imitates a client routing service posting navigation
* notifications.
*/
class NavigationNotificationService : Service() {
/**
* The number of notifications fired so far.
*
*
* We use this number to post notifications with a repeating list of directions. See [ ][.getDirectionInfo] for details.
*
* Note: Package private for inner class reference
*/
var mNotificationCount: Int = 0
/**
* A handler that posts notifications when given the message request. See [ ] for details.
*
* Note: Package private for inner class reference
*/
val mHandler: Handler =
Handler(Looper.getMainLooper(), HandlerCallback())
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val message = intent.getStringExtra("EXTRA_MESSAGE") ?: "Navigating..."
initNotifications(this)
val notification = getNavigationNotification(this, message)
// This updates the existing notification if the service is already running
CarNotificationManager.from(this).notify(NAV_NOTIFICATION_ID, notification)
startForeground(NAV_NOTIFICATION_ID, notification.build())
return START_NOT_STICKY
}
override fun onDestroy() {
mHandler.removeMessages(MSG_SEND_NOTIFICATION)
}
override fun onBind(intent: Intent): IBinder? {
return null
}
/**
* A [Handler.Callback] used to process the message queue for the notification service.
*/
internal inner class HandlerCallback : Handler.Callback {
override fun handleMessage(msg: Message): Boolean {
Log.d(TAG, "Notification handleMessage: $msg")
if (msg.what == MSG_SEND_NOTIFICATION) {
val context: Context = this@NavigationNotificationService
CarNotificationManager.from(context).notify(
NAV_NOTIFICATION_ID,
getNavigationNotification(context, "Nachricht")
)
mNotificationCount++
mHandler.sendMessageDelayed(
mHandler.obtainMessage(MSG_SEND_NOTIFICATION),
NAV_NOTIFICATION_DELAY_IN_MILLIS
)
return true
}
return false
}
}
/**
* A container class that encapsulates the direction information to use in the notifications.
*/
internal class DirectionInfo(
val mTitle: String, val mDistance: String, val mIcon: Int,
val mOnlyAlertOnce: Boolean
)
fun startForeground(message: String) {
startForeground(
NAV_NOTIFICATION_ID,
getNavigationNotification(this, message).build()
)
}
companion object {
private const val MSG_SEND_NOTIFICATION = 1
private const val NAV_NOTIFICATION_CHANNEL_ID = "nav_channel_00"
private val NAV_NOTIFICATION_CHANNEL_NAME: CharSequence = "Navigation Channel"
private const val NAV_NOTIFICATION_ID = 10101
val NAV_NOTIFICATION_DELAY_IN_MILLIS: Long = TimeUnit.SECONDS.toMillis(1)
/**
* Initializes the notifications, if needed.
*
*
* [NotificationManager.IMPORTANCE_HIGH] is needed to show the alerts on top of the car
* screen. However, the rail widget at the bottom of the screen will show regardless of the
* importance setting.
*/
// Suppressing 'ObsoleteSdkInt' as this code is shared between APKs with different min SDK
// levels
@SuppressLint("ObsoleteSdkInt")
private fun initNotifications(context: Context) {
val navChannel =
NotificationChannelCompat.Builder(
NAV_NOTIFICATION_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_HIGH
)
.setName(NAV_NOTIFICATION_CHANNEL_NAME).build()
CarNotificationManager.from(context).createNotificationChannel(navChannel)
}
/**
* Returns a [DirectionInfo] that corresponds to the given notification count.
*
*
* There are 5 directions, repeating in order. For each direction, the alert will only show
* once, but the distance will update on every count on the rail widget.
*/
private fun getDirectionInfo(context: Context, message: String): DirectionInfo {
val formatter = DecimalFormat("#.##")
formatter.setRoundingMode(RoundingMode.DOWN)
val distance = formatter.format((10) * 0.1) + "km"
return DirectionInfo(
message,
distance,
R.drawable.navigation_48px,
false
)
}
/** Returns the navigation notification that corresponds to the given notification count. */
fun getNavigationNotification(
context: Context
): NotificationCompat.Builder {
val builder =
NotificationCompat.Builder(context, NAV_NOTIFICATION_CHANNEL_ID)
val directionInfo = getDirectionInfo(context, "Test")
// Set an intent to open the car app. The app receives this intent when the user taps the
// heads-up notification or the rail widget.
val pendingIntent = CarPendingIntent.getCarApp(
context,
NavigationCarAppService().intentActionNavNotificationOpenApp.hashCode(),
Intent(
NavigationCarAppService().intentActionNavNotificationOpenApp
).setComponent(
ComponentName(
context,
NavigationCarAppService()::class.java
)
).setData(
NavigationCarAppService().createDeepLinkUri(
NavigationCarAppService().intentActionNavNotificationOpenApp
)
),
0
)
return builder // This title, text, and icon will be shown in both phone and car screen. These
// values can
// be overridden in the extender below, to customize notifications in the car
// screen.
.setContentTitle(directionInfo.mTitle)
.setContentText(directionInfo.mDistance)
.setSmallIcon(directionInfo.mIcon) // The notification must be set to 'ongoing' and its category must be set to
// CATEGORY_NAVIGATION in order to show it in the rail widget when the app is
// navigating on
// the background.
// These values cannot be overridden in the extender.
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_NAVIGATION) // If set to true, the notification will only show the alert once in both phone and
// car screen. This value cannot be overridden in the extender.
.setOnlyAlertOnce(directionInfo.mOnlyAlertOnce) // This extender must be set in order to display the notification in the car screen.
// The extender also allows various customizations, such as showing different title
// or icon on the car screen.
.extend(
CarAppExtender.Builder()
.setContentIntent(pendingIntent)
.build()
)
}
fun getNavigationNotification(
context: Context, message: String
): NotificationCompat.Builder {
val builder =
NotificationCompat.Builder(context, NAV_NOTIFICATION_CHANNEL_ID)
val directionInfo = getDirectionInfo(context, message)
// Set an intent to open the car app. The app receives this intent when the user taps the
// heads-up notification or the rail widget.
val pendingIntent = CarPendingIntent.getCarApp(
context,
NavigationCarAppService().intentActionNavNotificationOpenApp.hashCode(),
Intent(
NavigationCarAppService().intentActionNavNotificationOpenApp
).setComponent(
ComponentName(
context,
NavigationCarAppService()::class.java
)
).setData(
NavigationCarAppService().createDeepLinkUri(
NavigationCarAppService().intentActionNavNotificationOpenApp
)
),
0
)
return builder
// This title, text, and icon will be shown in both phone and car screen. These
// values can
// be overridden in the extender below, to customize notifications in the car
// screen.
.setContentTitle(directionInfo.mTitle)
.setContentText(directionInfo.mDistance)
.setSmallIcon(directionInfo.mIcon) // The notification must be set to 'ongoing' and its category must be set to
// CATEGORY_NAVIGATION in order to show it in the rail widget when the app is
// navigating on
// the background.
// These values cannot be overridden in the extender.
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_NAVIGATION) // If set to true, the notification will only show the alert once in both phone and
// car screen. This value cannot be overridden in the extender.
.setOnlyAlertOnce(directionInfo.mOnlyAlertOnce) // This extender must be set in order to display the notification in the car screen.
// The extender also allows various customizations, such as showing different title
// or icon on the car screen.
.extend(
CarAppExtender.Builder()
.setContentIntent(pendingIntent)
.build()
)
}
}
}
@@ -0,0 +1,724 @@
package com.kouros.navigation.car
import android.Manifest.permission
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.location.Location
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.connection.CarConnection
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.navigation.car.navigation.NavigationService
import com.kouros.navigation.car.screen.NavigationListener
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.car.screen.NavigationType
import com.kouros.navigation.car.screen.RequestPermissionScreen
import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.car.screen.checkPermission
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
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.TAG
import com.kouros.navigation.data.Constants.TRAFFIC_UPDATE
import com.kouros.navigation.data.Constants.homeHohenwaldeck
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
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.NavigationUtils.getViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.absoluteValue
/**
* Main session for Android Auto/Automotive OS navigation.
* Manages the lifecycle of the navigation session, including location updates,
* car hardware sensors, routing engine selection, and screen navigation.
* Implements NavigationScreen.Listener for handling navigation events.
*/
class NavigationServiceSession : CarSession(), NavigationListener, NavigationObserverCallback {
// Flag to enable/disable contact access feature
val useContacts = false
var route = ""
// Main navigation screen displayed to the user
lateinit var navigationScreen: NavigationScreen
// Handles map surface rendering on the car display
lateinit var surfaceRenderer: SurfaceRenderer
// Manages car hardware sensors (location, compass, speed)
lateinit var carSensorManager: CarSensorManager
var initialLocation = true;
lateinit var textToSpeechManager: TextToSpeechManager
lateinit var notificationManager: NotificationManager
private var routingEngine = 0
private var showTraffic = false;
private var distanceMode = 0
var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
var recentPlaces = mutableListOf<Place>()
var lastRouteDate: LocalDateTime = LocalDateTime.now()
var destination = Place()
var notificationActive = false
var navigationService: NavigationService? = null
val serviceListener: NavigationService.Listener = object : NavigationService.Listener {
override fun navigationStateChanged(
isNavigating: Boolean,
isRerouting: Boolean,
hasArrived: Boolean,
destinations: MutableList<Destination>,
steps: MutableList<Step>,
destinationTravelEstimate: TravelEstimate,
stepTravelEstimate: TravelEstimate,
stepRemainingDistance: Distance,
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?,
backGroundColor: CarColor
) {
navigationScreen.updateTrip(
isNavigating = isNavigating,
isRerouting = isRerouting,
hasArrived = hasArrived,
destinationTravelEstimate = destinationTravelEstimate,
stepTravelEstimate = stepTravelEstimate,
destinations = destinations,
steps = steps,
stepRemainingDistance = stepRemainingDistance,
shouldShowNextStep = shouldShowNextStep,
shouldShowLanes = shouldShowLanes,
junctionImage = junctionImage,
backGroundColor = backGroundColor
)
}
override fun updateServiceLocation(location: Location) {
if (initialLocation) {
navigationViewModel.loadRecentPlaces(
carContext,
location,
surfaceRenderer.carOrientation,
)
initialLocation = false
}
updateLocation(location)
}
}
// Monitors the state of the connection to the Navigation service.
val serviceConnection: ServiceConnection = object : ServiceConnection {
@RequiresPermission(allOf = [permission.ACCESS_FINE_LOCATION, permission.ACCESS_COARSE_LOCATION])
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d("NavigationService", "In onServiceConnected() Session component:$service")
val binder: NavigationService.LocalBinder = service as NavigationService.LocalBinder
navigationService = binder.service
navigationService!!.setCarContext(carContext, serviceListener)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d("NavigationService", "In onServiceDisconnected() Session component: $name")
// Unhook map models here
navigationService!!.clearCarContext()
navigationService = null
}
}
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
*/
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Log.i(TAG, "In onStart() Session")
carContext
.bindService(
Intent(carContext, NavigationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onPause(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onStop(owner: LifecycleOwner) {
Log.i(TAG, "In onStop()")
carContext.unbindService(serviceConnection)
navigationService = null
}
override fun onDestroy(owner: LifecycleOwner) {
if (::carSensorManager.isInitialized) {
carSensorManager.cleanup()
}
if (::textToSpeechManager.isInitialized) {
textToSpeechManager.cleanup()
}
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
Log.i(TAG, "NavigationSession destroyed")
}
}
// ViewModel for navigation data and business logic
lateinit var navigationViewModel: NavigationViewModel
// Store for ViewModels to survive configuration changes
lateinit var viewModelStoreOwner: ViewModelStoreOwner
var lastStepIndex = -1
var guidanceAudio = 0
var lastTrafficDate: LocalDateTime = LocalDateTime.MIN
lateinit var observerManager: NavigationObserverManager
lateinit var repository: SettingsRepository
lateinit var settingsViewModel: SettingsViewModel
var carConnection: Int = 0
init {
lifecycle.addObserver(lifecycleObserver)
}
/**
* Called when routing engine preference changes.
* Creates appropriate repository based on user selection.
*/
fun onRoutingEngineStateUpdated(routeEngine: Int) {
Log.d(TAG, "onRoutingEngineStateUpdated $routeEngine")
if (!::navigationViewModel.isInitialized || routeEngine != routingEngine) {
navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository())
RouteEngine.OSRM.ordinal -> NavigationViewModel(OsrmRepository())
else -> NavigationViewModel(TomTomRepository())
}
observerManager = NavigationObserverManager(navigationViewModel, this)
observerManager.attachAllObservers(this)
}
}
/**
* Called when location permission is granted.
* Initializes car hardware sensors if available.
*/
fun onPermissionGranted(permission: Boolean) {
if (::carSensorManager.isInitialized && permission) {
carSensorManager.updateConnectionState(carConnection)
}
}
/**
* Called when car connection state changes.
* Handles different connection types: Not Connected, Automotive OS Native, Android Auto Projection.
* Requests appropriate car speed permissions based on connection type.
*/
fun onConnectionStateUpdated(connectionState: Int) {
carConnection = connectionState
when (connectionState) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
CarConnection.CONNECTION_TYPE_NATIVE -> {
navigationViewModel.permissionGranted.value =
checkPermission(carContext, AUTOMOTIVE_CAR_SPEED_PERMISSION)
}
CarConnection.CONNECTION_TYPE_PROJECTION -> {
navigationViewModel.permissionGranted.value =
checkPermission(carContext, GMS_CAR_SPEED_PERMISSION)
}
}
}
/**
* Creates the initial screen for the session.
* Sets up ViewModel store, initializes settings, components, checks permissions,
* and returns appropriate starting screen.
*/
override fun onCreateScreen(intent: Intent): Screen {
initializeSettings()
setupViewModelStore()
initializeManagers()
initializeViewModels()
initializeScreen()
return checkPermissionsAndGetScreen()
}
/*
* Initializes the settings repository and ViewModel.
*/
private fun initializeSettings() {
repository = getSettingsRepository(carContext)
settingsViewModel = getSettingsViewModel(carContext)
repository.routingEngineFlow.asLiveData().observe(this, Observer {
onRoutingEngineStateUpdated(it)
routingEngine = it
})
repository.trafficFlow.asLiveData().observe(this, Observer {
showTraffic = it
})
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
/**
* Sets up ViewModelStoreOwner and manages its lifecycle.
*/
private fun setupViewModelStore() {
viewModelStoreOwner = object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
lifecycleScope.launch {
try {
awaitCancellation()
} finally {
viewModelStoreOwner.viewModelStore.clear()
}
}
}
/**
* Initializes ViewModels and observes their state changes.
*/
private fun initializeViewModels() {
navigationViewModel = getViewModel(carContext)
navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated)
navigationViewModel.permissionGranted.observe(this, ::onPermissionGranted)
CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated)
}
/**
* Initializes managers for rendering, sensors, and location.
*/
private fun initializeManagers() {
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner, this)
carSensorManager = CarSensorManager(
carContext = carContext,
lifecycleOwner = this,
onLocationUpdate = ::updateLocation,
onCompassUpdate = { orientation -> surfaceRenderer.carOrientation = orientation },
onSpeedUpdate = { speed -> surfaceRenderer.updateCarSpeed(speed) }
)
textToSpeechManager = TextToSpeechManager(carContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it
})
notificationManager = NotificationManager(carContext, this)
}
/**
* Creates the main navigation screen.
*/
private fun initializeScreen() {
navigationScreen = NavigationScreen(
carContext,
surfaceRenderer,
this,
navigationViewModel
)
}
/**
* Checks required permissions and returns appropriate screen.
* Shows permission request screen if needed, otherwise starts location updates.
*/
private fun checkPermissionsAndGetScreen(): Screen {
val hasLocationPermission =
carContext.checkSelfPermission(permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val hasContactsPermission = !useContacts ||
carContext.checkSelfPermission(permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
return if (hasLocationPermission && hasContactsPermission) {
navigationScreen
} else {
showPermissionScreen()
}
}
/**
* Shows the permission request screen.
*/
private fun showPermissionScreen(): Screen {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(navigationScreen)
return RequestPermissionScreen(
carContext,
listOf(
permission.ACCESS_COARSE_LOCATION,
permission.ACCESS_FINE_LOCATION,
),
permissionCheckCallback = { screenManager.pop() }
)
}
/**
* Handles new intents, primarily for navigation deep links from other apps.
* Supports ACTION_NAVIGATE for starting navigation to a specific location.
*/
override fun onNewIntent(intent: Intent) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
// Handle Android Auto ACTION_NAVIGATE intent
if (CarContext.ACTION_NAVIGATE == intent.action) {
handleNavigateIntent(screenManager)
return
}
// Handle custom deep links
handleDeepLink(intent, screenManager)
}
/**
* Handles ACTION_NAVIGATE intent by showing search screen.
*/
private fun handleNavigateIntent(screenManager: ScreenManager) {
screenManager.popToRoot()
screenManager.pushForResult(
SearchScreen(carContext, surfaceRenderer, navigationViewModel, recentPlaces)
) { result ->
// Handle search result if needed
}
}
/**
* Handles custom deep link URIs.
*/
private fun handleDeepLink(intent: Intent, screenManager: ScreenManager) {
val uri = intent.data ?: return
if (uri.scheme != uriScheme || uri.schemeSpecificPart != uriHost) return
when (uri.fragment) {
"DEEP_LINK_ACTION" -> {
if (screenManager.getTop() !is NavigationScreen) {
screenManager.popToRoot()
}
}
}
}
/**
* Updates navigation state with new location.
* Handles route snapping, deviation detection for rerouting, and map updates.
*/
fun updateLocation(location: Location) {
if (carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
surfaceRenderer.updateCarSpeed(location.speed)
}
updateBearing(location)
checkTraffic(LocalDateTime.now(ZoneOffset.UTC), location)
surfaceRenderer.updateLocation(location, "")
}
/**
* Updates route bearing if location has bearing information.
*/
private fun updateBearing(location: Location) {
if (location.hasBearing()) {
//routeModel.navState = routeModel.navState.copy(routeBearing = location.bearing)
}
}
/**
* Start navigation process.
* Called when user starts navigation
*/
override fun startNavigation() {
Log.d(TAG, "startNavigation")
navigationService!!.startNavigation(route, destination)
if (notificationActive)
notificationManager.startNotificationService()
}
/**
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation() {
Log.d(TAG, "stopNavigation")
navigationService!!.stopNavigation()
surfaceRenderer.navigation = false
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
if (notificationActive)
notificationManager.stopNotificationService()
Log.d(TAG, "end stopNavigation")
}
override fun updateTrip(trip: Trip) {
}
/**
* Recalculates a route for the specified place.
*/
override fun recalcRoute(destination: Place) {
val destination = location(destination.longitude, destination.latitude)
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
listOf(destination),
surfaceRenderer.carOrientation
)
}
/**
* Handles the received route string.
* Starts navigation and invalidates the screen.
*/
override fun onRouteReceived(route: String) {
Log.d(TAG, "onRouteReceived")
if (route.isNotEmpty()) {
prepareRoute(route)
}
}
override fun isNavigating(): Boolean {
return navigationService!!.isNavigating()
}
/**
* Prepare route and start navigation
*/
private fun prepareRoute(route: String) {
this.route = route
startNavigation()
surfaceRenderer.setRouteData(navigationService!!.routeModel.curRoute.routeGeoJson)
}
/**
* Handles received traffic data and updates the surface renderer.
*/
override fun onTrafficReceived(traffic: Map<String, String>) {
if (traffic.isNotEmpty()) {
surfaceRenderer.setTrafficData(traffic)
}
}
/**
* Handles the received place search result.
* Navigates to the specified place.
*/
override fun onPlaceSearchResultReceived(place: Place) {
navigateToPlace(place)
}
/**
* Handles received speed camera data.
* Updates the surface renderer with the camera locations.
*/
override fun onSpeedCamerasReceived(cameras: List<Elements>) {
speedCameras = cameras
val coordinates = mutableListOf<List<Double>>()
cameras.forEach {
coordinates.add(listOf(it.lon, it.lat))
}
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
surfaceRenderer.speedCameraData.value = speedData
}
/**
* Handles received maximum speed data and updates the surface renderer.
*/
override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed
}
override fun onRecentPlacesReceived(places: List<Place>) {
Log.d(TAG, "onRecentPlacesReceived ${places.size}")
recentPlaces = places.toMutableList()
navigationScreen.recentPlaces = places.toMutableList()
navigationScreen.invalidate()
}
override fun invalidateScreen() {
navigationScreen.invalidate()
}
/**
* Loads a route to the specified place and sets it as the destination.
*/
override fun navigateToPlace(place: Place) {
Log.d(TAG, "navigateToPlace ${place.street}")
var prevDestination = Place()
if (surfaceRenderer.navigation) {
prevDestination = place
stopNavigation()
}
val preview = place.route
navigationViewModel.previewRoute.value = ""
val location = if (place.stopOver && prevDestination.latitude != 0.0) {
listOf(
location(place.longitude, place.latitude),
location(prevDestination.longitude, prevDestination.latitude)
)
} else {
listOf(location(place.longitude, place.latitude))
}
navigationViewModel.saveRecent(carContext, place)
destination = place
// routeModel.navState = routeModel.navState.copy(destination = place)
if (preview.isEmpty()) {
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
location,
surfaceRenderer.carOrientation
)
} else {
//routeModel.navState = routeModel.navState.copy(currentRouteIndex = place.routeIndex)
onRouteReceived(preview)
}
surfaceRenderer.activateNavigationView()
}
/**
* Checks if traffic data needs to be updated based on the time since the last update.
*/
fun checkTraffic(current: LocalDateTime, location: Location) {
val duration = Duration.between(current, lastTrafficDate)
if (showTraffic && duration.abs().seconds > TRAFFIC_UPDATE) {
lastTrafficDate = current
navigationViewModel.loadTraffic(carContext, location, surfaceRenderer.carOrientation)
}
}
/**
* Periodically requests speed camera information near the current location.
*/
private fun updateSpeedCamera(location: Location) {
if (lastCameraSearch++ % 100 == 0) {
navigationViewModel.getSpeedCameras(location, 5.0)
}
if (speedCameras.isNotEmpty()) {
updateDistance(location)
}
}
/**
* Updates distances to nearby speed cameras and checks for proximity alerts.
*/
private fun updateDistance(
location: Location,
) {
val updatedCameras = mutableListOf<Elements>()
speedCameras.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.firstOrNull() ?: return
val bearingRoute = surfaceRenderer.lastLocation.bearingTo(location)
val bearingSpeedCamera = if (camera.tags.direction != null) {
try {
camera.tags.direction!!.toFloat()
} catch (e: Exception) {
0F
}
} else {
location.bearingTo(location(camera.lon, camera.lat)).absoluteValue
}
if (camera.distance < 80) {
if ((bearingSpeedCamera - bearingRoute.absoluteValue).absoluteValue < 15.0) {
// routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
}
}
}
override fun invalidateNavigationScreen() {
navigationScreen.invalidate()
}
companion object {
// URI host for deep linking
var uriHost: String = "navigation"
// URI scheme for deep linking
var uriScheme: String = "samples"
}
}
@@ -1,193 +1,887 @@
package com.kouros.navigation.car
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.Manifest.permission
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.core.location.LocationListenerCompat
import androidx.core.net.toUri
import androidx.car.app.connection.CarConnection
import androidx.car.app.model.Distance
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.NavigationManagerCallback
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
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.Observer
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.asLiveData
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.navigation.Simulation
import com.kouros.navigation.car.screen.NavigationListener
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.car.screen.NavigationType
import com.kouros.navigation.car.screen.RequestPermissionScreen
import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.car.screen.checkPermission
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
import com.kouros.navigation.data.Constants.AUTOMOTIVE_CAR_SPEED_PERMISSION
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
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
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.Constants.TRAFFIC_UPDATE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.absoluteValue
class NavigationSession : Session(), NavigationScreen.Listener {
/**
* Main session for Android Auto/Automotive OS navigation.
* Manages the lifecycle of the navigation session, including location updates,
* car hardware sensors, routing engine selection, and screen navigation.
* Implements NavigationScreen.Listener for handling navigation events.
*/
class NavigationSession : CarSession(), NavigationListener, NavigationObserverCallback {
// Flag to enable/disable contact access feature
val useContacts = false
lateinit var routeModel: RouteCarModel;
// Model for managing route state and navigation logic for Android Auto
lateinit var routeModel: RouteCarModel
var route = ""
// Main navigation screen displayed to the user
lateinit var navigationScreen: NavigationScreen
// Handles map surface rendering on the car display
lateinit var surfaceRenderer: SurfaceRenderer
var mLocationListener: LocationListenerCompat = LocationListenerCompat { location: Location? ->
updateLocation(location!!)
}
// Manages car hardware sensors (location, compass, speed)
lateinit var carSensorManager: CarSensorManager
private val mLifeCycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
Log.i(TAG, "In onCreate()")
// Manages device GPS location updates
lateinit var deviceLocationManager: DeviceLocationManager
lateinit var navigationManager: NavigationManager
lateinit var textToSpeechManager: TextToSpeechManager
lateinit var notificationManager: NotificationManager
var autoDriveEnabled = false
val simulation = Simulation()
private var routingEngine = 0
private var showTraffic = false;
private var distanceMode = 0
var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
var lastRouteDate: LocalDateTime = LocalDateTime.now()
var navigationManagerStarted = false
var notificationActive = false
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
*/
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.i(TAG, "In onResume()")
}
override fun onPause(owner: LifecycleOwner) {
Log.i(TAG, "In onPause()")
}
override fun onStop(owner: LifecycleOwner) {
Log.i(TAG, "In onStop()")
Log.d(TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onDestroy(owner: LifecycleOwner) {
Log.i(TAG, "In onDestroy()")
val locationManager =
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
locationManager.removeUpdates(mLocationListener)
if (::navigationManager.isInitialized) {
navigationManager.clearNavigationManagerCallback()
}
if (::carSensorManager.isInitialized) {
carSensorManager.cleanup()
}
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
if (::textToSpeechManager.isInitialized) {
textToSpeechManager.cleanup()
}
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
Log.i(TAG, "NavigationSession destroyed")
}
}
val navigationViewModel = ViewModel(ValhallaRepository())
// ViewModel for navigation data and business logic
lateinit var navigationViewModel: NavigationViewModel
// Store for ViewModels to survive configuration changes
lateinit var viewModelStoreOwner: ViewModelStoreOwner
var lastStepIndex = -1
var guidanceAudio = 0
var lastTrafficDate: LocalDateTime = LocalDateTime.MIN
lateinit var observerManager: NavigationObserverManager
lateinit var repository: SettingsRepository
lateinit var settingsViewModel: SettingsViewModel
init {
val lifecycle: Lifecycle = lifecycle
lifecycle.addObserver(mLifeCycleObserver)
lifecycle.addObserver(lifecycleObserver)
}
/**
* Called when routing engine preference changes.
* Creates appropriate repository based on user selection.
*/
fun onRoutingEngineStateUpdated(routeEngine: Int) {
if (!::navigationViewModel.isInitialized || routeEngine != routingEngine) {
navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository())
RouteEngine.OSRM.ordinal -> NavigationViewModel(OsrmRepository())
else -> NavigationViewModel(TomTomRepository())
}
observerManager = NavigationObserverManager(navigationViewModel, this)
observerManager.attachAllObservers(this)
}
}
/**
* Called when location permission is granted.
* Initializes car hardware sensors if available.
*/
fun onPermissionGranted(permission: Boolean) {
if (::carSensorManager.isInitialized && permission) {
carSensorManager.updateConnectionState(routeModel.navState.carConnection)
}
}
/**
* Called when car connection state changes.
* Handles different connection types: Not Connected, Automotive OS Native, Android Auto Projection.
* Requests appropriate car speed permissions based on connection type.
*/
fun onConnectionStateUpdated(connectionState: Int) {
routeModel.navState = routeModel.navState.copy(carConnection = connectionState)
when (connectionState) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
CarConnection.CONNECTION_TYPE_NATIVE -> {
navigationViewModel.permissionGranted.value =
checkPermission(carContext, AUTOMOTIVE_CAR_SPEED_PERMISSION)
}
CarConnection.CONNECTION_TYPE_PROJECTION -> {
navigationViewModel.permissionGranted.value =
checkPermission(carContext, GMS_CAR_SPEED_PERMISSION)
}
}
}
/**
* Creates the initial screen for the session.
* Sets up ViewModel store, initializes settings, components, checks permissions,
* and returns appropriate starting screen.
*/
override fun onCreateScreen(intent: Intent): Screen {
initializeSettings()
setupViewModelStore()
initializeViewModels()
initializeManagers()
initializeScreen()
return checkPermissionsAndGetScreen()
}
/*
* Initializes the settings repository and ViewModel.
*/
private fun initializeSettings() {
repository = getSettingsRepository(carContext)
settingsViewModel = getSettingsViewModel(carContext)
repository.routingEngineFlow.asLiveData().observe(this, Observer {
onRoutingEngineStateUpdated(it)
routingEngine = it
})
repository.trafficFlow.asLiveData().observe(this, Observer {
showTraffic = it
})
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
/**
* Sets up ViewModelStoreOwner and manages its lifecycle.
*/
private fun setupViewModelStore() {
viewModelStoreOwner = object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
lifecycleScope.launch {
try {
awaitCancellation()
} finally {
viewModelStoreOwner.viewModelStore.clear()
}
}
}
/**
* Initializes ViewModels and observes their state changes.
*/
private fun initializeViewModels() {
navigationViewModel = getViewModel(carContext)
navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated)
navigationViewModel.permissionGranted.observe(this, ::onPermissionGranted)
routeModel = RouteCarModel()
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel)
navigationScreen = NavigationScreen(carContext, surfaceRenderer, routeModel, this, navigationViewModel)
if (carContext.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED && !useContacts
|| (useContacts && carContext.checkSelfPermission(Manifest.permission.READ_CONTACTS)
== PackageManager.PERMISSION_GRANTED)
) {
requestLocationUpdates()
} else {
// If we do not have the location permission, show the request permission screen.
val screenManager =
carContext.getCarService(ScreenManager::class.java)
screenManager
.push(navigationScreen)
return RequestPermissionScreen(
carContext,
mLocationPermissionCheckCallback = {
screenManager.pop()
},
mContactsPermissionCheckCallback = {
screenManager.pop()
CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated)
}
/**
* 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)
deviceLocationManager.stopLocationUpdates()
autoDriveEnabled = true
startNavigation()
CarToast.makeText(carContext, "Auto drive enabled", CarToast.LENGTH_LONG)
.show()
}
override fun onStopNavigation() {
// Called when the user stops navigation in the car screen
// Stop turn-by-turn logic and clean up
stopNavigation()
if (autoDriveEnabled) {
deviceLocationManager.startLocationUpdates()
}
}
})
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner, this)
carSensorManager = CarSensorManager(
carContext = carContext,
lifecycleOwner = this,
onLocationUpdate = ::updateLocation,
onCompassUpdate = { orientation -> surfaceRenderer.carOrientation = orientation },
onSpeedUpdate = { speed -> surfaceRenderer.updateCarSpeed(speed) }
)
deviceLocationManager = DeviceLocationManager(
carContext = carContext,
lifecycleOwner = this,
shouldUseCarLocationFlow = carSensorManager.shouldUseCarLocation(),
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
navigationViewModel.loadRecentPlaces(
carContext,
location,
surfaceRenderer.carOrientation,
)
}
return navigationScreen
)
textToSpeechManager = TextToSpeechManager(carContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it
})
notificationManager = NotificationManager(carContext, this)
}
override fun onNewIntent(intent: Intent) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
if ((CarContext.ACTION_NAVIGATE == intent.action)) {
val uri = ("http://" + intent.dataString).toUri()
val location = Location(LocationManager.GPS_PROVIDER)
screenManager.popToRoot()
screenManager.pushForResult(
SearchScreen(
/**
* Creates the main navigation screen.
*/
private fun initializeScreen() {
navigationScreen = NavigationScreen(
carContext,
surfaceRenderer,
location,
navigationViewModel,
// TODO: Uri
this,
navigationViewModel
)
) { obj: Any? ->
if (obj != null) {
}
/**
* Checks required permissions and returns appropriate screen.
* Shows permission request screen if needed, otherwise starts location updates.
*/
private fun checkPermissionsAndGetScreen(): Screen {
val hasLocationPermission =
carContext.checkSelfPermission(permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val hasContactsPermission = !useContacts ||
carContext.checkSelfPermission(permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
return if (hasLocationPermission && hasContactsPermission) {
deviceLocationManager.startLocationUpdates()
navigationScreen
} else {
showPermissionScreen()
}
}
/**
* Shows the permission request screen.
*/
private fun showPermissionScreen(): Screen {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(navigationScreen)
return RequestPermissionScreen(
carContext,
listOf(
permission.ACCESS_COARSE_LOCATION,
permission.ACCESS_FINE_LOCATION,
),
permissionCheckCallback = { screenManager.pop() }
)
}
/**
* Handles new intents, primarily for navigation deep links from other apps.
* Supports ACTION_NAVIGATE for starting navigation to a specific location.
*/
override fun onNewIntent(intent: Intent) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
// Handle Android Auto ACTION_NAVIGATE intent
if (CarContext.ACTION_NAVIGATE == intent.action) {
handleNavigateIntent(screenManager)
return
}
val uri = intent.data
if (uri != null && uriScheme == uri.scheme
&& uriHost == uri.schemeSpecificPart
) {
val top = screenManager.getTop()
// Handle custom deep links
handleDeepLink(intent, screenManager)
}
/**
* Handles ACTION_NAVIGATE intent by showing search screen.
*/
private fun handleNavigateIntent(screenManager: ScreenManager) {
screenManager.popToRoot()
screenManager.pushForResult(
SearchScreen(carContext, surfaceRenderer, navigationViewModel, mutableListOf())
) { result ->
// Handle search result if needed
}
}
/**
* Handles custom deep link URIs.
*/
private fun handleDeepLink(intent: Intent, screenManager: ScreenManager) {
val uri = intent.data ?: return
if (uri.scheme != uriScheme || uri.schemeSpecificPart != uriHost) return
when (uri.fragment) {
"DEEP_LINK_ACTION" -> if (top !is NavigationScreen) {
"DEEP_LINK_ACTION" -> {
if (screenManager.getTop() !is NavigationScreen) {
screenManager.popToRoot()
}
else -> {}
}
}
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
val locationManager =
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (location != null) {
/**
* Updates navigation state with new location.
* Handles route snapping, deviation detection for rerouting, and map updates.
*/
fun updateLocation(location: Location) {
val streetName = if (routeModel.isNavigating()) {
routeModel.currentStep().street
} else {
""
}
if (routeModel.navState.carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
surfaceRenderer.updateCarSpeed(location.speed)
}
updateBearing(location)
if (routeModel.isNavigating()) {
handleNavigationLocation(location)
} else {
checkTraffic(LocalDateTime.now(ZoneOffset.UTC), location)
surfaceRenderer.updateLocation(location, streetName)
}
}
/**
* Updates route bearing if location has bearing information.
*/
private fun updateBearing(location: Location) {
if (location.hasBearing()) {
routeModel.navState = routeModel.navState.copy(routeBearing = location.bearing)
}
}
/**
* Handles location updates during active navigation.
* Snaps location to route and checks for deviation requiring reroute.
*/
private fun handleNavigationLocation(location: Location) {
routeModel.updateLocation(location, navigationViewModel)
if (routeModel.navState.arrived) return
if (guidanceAudio == 1) {
handleGuidanceAudio()
}
val streetName = routeModel.currentStep().street
val currentDate = LocalDateTime.now(ZoneOffset.UTC)
if (snapLocation(location, streetName)) {
checkTraffic(currentDate, location)
updateSpeedCamera(location)
checkRoute(currentDate, location)
updateNavigationScreen()
checkArrival()
}
}
/**
* Updates the surface renderer with snapped location and street name.
* Checks if maximal route deviation is exceeded and reroutes if needed.
*/
private fun snapLocation(location: Location, streetName: String): Boolean {
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snappedLocation)
when {
distance > MAXIMAL_ROUTE_DEVIATION -> {
stopNavigation()
navigationScreen.calculateNewRoute(routeModel.navState.destination)
return false
}
distance < MAXIMAL_SNAP_CORRECTION -> {
surfaceRenderer.updateLocation(snappedLocation, streetName)
}
else -> {
surfaceRenderer.updateLocation(location, streetName)
}
}
return true
}
/**
* Updates the navigation screen with new trip information.
*/
fun updateNavigationScreen() {
if (routeModel.isNavigating() && routeModel.navState.destination.name.isEmpty()
&& routeModel.navState.destination.street.isEmpty()
) {
return
}
val travelEstimateTrip = routeModel.travelEstimateTrip(carContext, distanceMode)
val travelEstimateStep = routeModel.travelEstimateStep(carContext, distanceMode)
val steps = mutableListOf<Step>()
val destination = Destination.Builder()
.setName(routeModel.navState.destination.name)
.setAddress(routeModel.navState.destination.street)
.build()
val distance =
formattedDistance(0, routeModel.routeCalculator.leftStepDistance())
steps.add(routeModel.currentStep(carContext))
if (routeModel.navState.nextStep) {
steps.add(routeModel.nextStep(carContext = carContext))
}
navigationScreen.updateTrip(
isNavigating = routeModel.isNavigating(),
isRerouting = false,
hasArrived = routeModel.isArrival(),
destinationTravelEstimate = travelEstimateTrip,
stepTravelEstimate = travelEstimateStep,
destinations = mutableListOf(destination),
steps = steps,
stepRemainingDistance = Distance.create(distance.first, distance.second),
shouldShowNextStep = false,
shouldShowLanes = true,
junctionImage = null,
backGroundColor = routeModel.backGroundColor()
)
/**
* Updates the trip information and notifies the listener with a new Trip object.
* This includes destination name, address, travel estimate, and loading status.
*/
val tripBuilder = Trip.Builder()
tripBuilder.addDestination(
destination,
travelEstimateTrip
)
tripBuilder.setLoading(false)
tripBuilder.setCurrentRoad(destination.name.toString())
tripBuilder.addStep(steps.first(), travelEstimateStep)
updateTrip(tripBuilder.build())
}
/**
* Checks for arrival
*/
fun checkArrival() {
if (routeModel.isArrival()
&& routeModel.routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
stopNavigation()
settingsViewModel.onLastRouteChanged("")
routeModel.navState = routeModel.navState.copy(arrived = true)
surfaceRenderer.routeData.value = ""
navigationScreen.navigationType = NavigationType.ARRIVAL
invalidateScreen()
}
}
/**
* Start navigation process.
* Called when user starts navigation
*/
override fun startNavigation() {
Log.d(TAG, "startNavigation")
surfaceRenderer.navigation = true
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationManager.navigationStarted()
navigationManagerStarted = true
if (autoDriveEnabled) {
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
) { location ->
updateLocation(location)
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
/* minTimeMs= */ 1000,
/* minDistanceM= */ 5f,
mLocationListener
}
}
if (notificationActive)
notificationManager.startNotificationService()
}
/**
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation() {
Log.d(TAG, "stopNavigation")
surfaceRenderer.navigation = false
routeModel.stopNavigation()
navigationManager.navigationEnded()
if (autoDriveEnabled) {
simulation.stopSimulation()
autoDriveEnabled = false
}
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
if (notificationActive)
notificationManager.stopNotificationService()
}
override fun updateTrip(trip: Trip) {
if (navigationManagerStarted) {
navigationManager.updateTrip(trip)
}
}
/**
* Recalculates a route for the specified place.
*/
override fun recalcRoute(destination: Place) {
val destination = location(destination.longitude, destination.latitude)
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
listOf(destination),
surfaceRenderer.carOrientation
)
}
/**
* Handle guidance audio
* Called when user wants to hear the step-by-step instructions
*/
private fun handleGuidanceAudio() {
val currentStep = routeModel.route.currentStep()
val stepData = routeModel.currentStep()
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) {
textToSpeechManager.speak(stepData.message)
lastStepIndex = currentStep.index
if (notificationActive) {
notificationManager.sendMessage(stepData.message)
}
}
}
/**
* Handles the received route string.
* Starts navigation and invalidates the screen.
*/
override fun onRouteReceived(route: String) {
Log.d(TAG, "onRouteReceived")
if (route.isNotEmpty()) {
this.route = route
if (routeModel.isNavigating()) {
updateRoute(route)
} else {
prepareRoute(route)
}
updateNavigationScreen()
}
}
/**
* Prepare route and start navigation
*/
private fun prepareRoute(route: String) {
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(route)
if (routeModel.hasLegs()) {
settingsViewModel.onLastRouteChanged(route)
}
surfaceRenderer.setRouteData(routeModel.curRoute.routeGeoJson)
startNavigation()
updateNavigationScreen()
}
/**
* Update route and traffic data
*/
private fun updateRoute(route: String) {
val newRouteModel = RouteModel()
newRouteModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
newRouteModel.startNavigation(route)
routeModel.curRoute.summary.trafficDelay = newRouteModel.curRoute.summary.trafficDelay
updateNavigationScreen()
}
override fun isNavigating(): Boolean = routeModel.isNavigating()
/**
* Handles received traffic data and updates the surface renderer.
*/
override fun onTrafficReceived(traffic: Map<String, String>) {
if (traffic.isNotEmpty()) {
surfaceRenderer.setTrafficData(traffic)
}
}
/**
* Handles the received place search result.
* Navigates to the specified place.
*/
override fun onPlaceSearchResultReceived(place: Place) {
navigateToPlace(place)
}
/**
* Handles received speed camera data.
* Updates the surface renderer with the camera locations.
*/
override fun onSpeedCamerasReceived(cameras: List<Elements>) {
speedCameras = cameras
val coordinates = mutableListOf<List<Double>>()
cameras.forEach {
coordinates.add(listOf(it.lon, it.lat))
}
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
surfaceRenderer.speedCameraData.value = speedData
}
/**
* Handles received maximum speed data and updates the surface renderer.
*/
override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed
}
override fun onRecentPlacesReceived(places: List<Place>) {
}
override fun invalidateScreen() {
navigationScreen.invalidate()
}
/**
* Loads a route to the specified place and sets it as the destination.
*/
override fun navigateToPlace(place: Place) {
Log.d(TAG, "navigateToPlace ${place.street}")
var prevDestination = Place()
if (surfaceRenderer.navigation) {
prevDestination = routeModel.navState.destination
stopNavigation()
}
val preview = place.route
navigationViewModel.previewRoute.value = ""
val location = if (place.stopOver && prevDestination.latitude != 0.0) {
listOf(
location(place.longitude, place.latitude),
location(prevDestination.longitude, prevDestination.latitude)
)
} else {
listOf(location(place.longitude, place.latitude))
}
navigationViewModel.saveRecent(carContext, place)
routeModel.navState = routeModel.navState.copy(destination = place)
if (preview.isEmpty()) {
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
location,
surfaceRenderer.carOrientation
)
} else {
routeModel.navState = routeModel.navState.copy(currentRouteIndex = place.routeIndex)
onRouteReceived(preview)
}
surfaceRenderer.activateNavigationView()
}
/**
* Checks if traffic data needs to be updated based on the time since the last update.
*/
fun checkTraffic(current: LocalDateTime, location: Location) {
val duration = Duration.between(current, lastTrafficDate)
if (showTraffic && duration.abs().seconds > TRAFFIC_UPDATE) {
lastTrafficDate = current
navigationViewModel.loadTraffic(carContext, location, surfaceRenderer.carOrientation)
}
}
/**
* Periodically requests speed camera information near the current location.
*/
private fun updateSpeedCamera(location: Location) {
if (lastCameraSearch++ % 100 == 0) {
navigationViewModel.getSpeedCameras(location, 5.0)
}
if (speedCameras.isNotEmpty()) {
updateDistance(location)
}
}
/**
* Updates distances to nearby speed cameras and checks for proximity alerts.
*/
private fun updateDistance(
location: Location,
) {
val updatedCameras = mutableListOf<Elements>()
speedCameras.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.firstOrNull() ?: return
val bearingRoute = surfaceRenderer.lastLocation.bearingTo(location)
val bearingSpeedCamera = if (camera.tags.direction != null) {
try {
camera.tags.direction!!.toFloat()
} catch (e: Exception) {
0F
}
} else {
location.bearingTo(location(camera.lon, camera.lat)).absoluteValue
}
if (camera.distance < 80) {
if ((bearingSpeedCamera - bearingRoute.absoluteValue).absoluteValue < 15.0) {
routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
}
}
}
/**
* Checks if a new route is needed based on the time since the last update.
*/
private fun checkRoute(currentDate: LocalDateTime, location: Location) {
val duration = Duration.between(currentDate, lastRouteDate)
val routeUpdate = routeModel.curRoute.summary.duration / 4
if (duration.abs().seconds > routeUpdate) {
lastRouteDate = currentDate
val destination = location(
routeModel.navState.destination.longitude,
routeModel.navState.destination.latitude
)
navigationViewModel.loadRoute(
carContext,
location,
listOf(destination),
surfaceRenderer.carOrientation
)
}
}
fun updateLocation(location: Location) {
if (routeModel.isNavigating()) {
val snapedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snapedLocation)
if (distance > MAXIMAL_ROUTE_DEVIATION) {
navigationScreen.calculateNewRoute(routeModel.routeState.destination)
return
override fun invalidateNavigationScreen() {
navigationScreen.invalidate()
}
navigationScreen.updateTrip(location)
if (distance < MAXIMAL_SNAP_CORRECTION) {
surfaceRenderer.updateLocation(snapedLocation)
} else {
surfaceRenderer.updateLocation(location)
}
} else {
surfaceRenderer.updateLocation(location)
}
}
override fun stopNavigation() {
routeModel.stopNavigation()
}
companion object {
// URI host for deep linking
var uriHost: String = "navigation"
// URI scheme for deep linking
var uriScheme: String = "samples"
}
}
@@ -0,0 +1,62 @@
package com.kouros.navigation.car
import android.content.Intent
import android.location.Location
import android.os.Message
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.navigation.data.Constants.TAG
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class NotificationManager(
private val carContext: CarContext,
private val lifecycleOwner: LifecycleOwner,
) {
private var notificationServiceStarted = false
private var serviceStarted = false
init {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.DESTROYED) {
if (notificationServiceStarted) {
stopNotificationService()
}
}
}
}
fun startNotificationService() {
val intent = Intent(carContext, NavigationNotificationService::class.java)
carContext.startForegroundService(intent)
notificationServiceStarted = true
}
fun stopNotificationService() {
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
notificationServiceStarted = false
}
fun sendMessage(message: String) {
val intent = Intent(carContext, NavigationNotificationService::class.java).apply {
putExtra("EXTRA_MESSAGE", message)
}
carContext.startForegroundService(intent)
}
}
@@ -5,91 +5,159 @@ import android.graphics.Rect
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.location.Location
import android.os.CountDownTimer
import android.os.Handler
import android.util.Log
import androidx.car.app.AppManager
import androidx.car.app.CarContext
import androidx.car.app.Session
import androidx.car.app.SurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.car.app.connection.CarConnection
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.kouros.navigation.car.map.DarkMode
import com.kouros.navigation.car.map.DrawNavigationImages
import com.kouros.navigation.car.map.MapLibre
import com.kouros.navigation.car.map.cameraState
import com.kouros.navigation.car.map.getPaddingValues
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TILT
import com.kouros.navigation.data.DarkMode
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.calculateTilt
import com.kouros.navigation.utils.calculateZoom
import com.kouros.navigation.utils.duration
import com.kouros.navigation.utils.location
import com.kouros.navigation.utils.previewZoom
import com.kouros.navigation.utils.settingsViewModel
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position
import java.time.LocalDateTime
/**
* Handles map rendering for Android Auto using a virtual display.
* Creates a VirtualDisplay to render Compose UI onto the car's surface.
* Manages camera position, zoom, tilt, and navigation state for the map view.
*/
class SurfaceRenderer(
private var carContext: CarContext, lifecycle: Lifecycle,
private var routeModel: RouteCarModel
private var carContext: CarContext,
lifecycle: Lifecycle,
private var viewModelStoreOwner: ViewModelStoreOwner,
private var navigationSession: CarSession
) : DefaultLifecycleObserver {
// Last known location for bearing calculations
var lastLocation = location(0.0, 0.0)
private val cameraPosition = MutableLiveData(
// Car orientation sensor value (999F means no valid orientation)
var carOrientation = 999F
// Current camera position state for the map
val cameraPosition = MutableLiveData(
CameraPosition(
zoom = 15.0,
target = Position(latitude = 48.1857475, longitude = 11.5793627)
zoom = 16.0,
)
)
// Visible area of the map surface (can change based on UI elements)
private var visibleArea = MutableLiveData(
Rect(0, 0, 0, 0)
)
// Stable area that won't change during scrolling
var stableArea = Rect()
// Surface dimensions
var width = 0
var height = 0
// Last bearing for smooth transitions
var lastBearing = 0.0
// LiveData for route GeoJSON data
val routeData = MutableLiveData("")
// Traffic incident data (incident ID to GeoJSON mapping)
val trafficData = MutableLiveData(emptyMap<String, String>())
// Speed camera locations as GeoJSON
val speedCameraData = MutableLiveData("")
// Current speed in km/h
val speed = MutableLiveData(0F)
lateinit var centerLocation: Location
// Speed limit for current road
val maxSpeed = MutableLiveData(0)
// Current street name
val street = MutableLiveData("")
// Current view mode (navigation, preview, etc.)
var viewStyle = ViewStyle.VIEW
// Flag to indicate if in navigation mode
var navigation = false
// Center location for route preview
lateinit var centerLocation: Location
// Route distance for calculating preview zoom
var previewDistance = 0.0
// Compose view for rendering the map
lateinit var mapView: ComposeView
var tilt = 55.0
var countDownTimerActive = false
// Camera tilt angle (default 60 degrees for navigation)
var tilt = TILT
var lastLocationUpdate: LocalDateTime = LocalDateTime.now()
// Map base style (day/night)
val style: MutableLiveData<BaseStyle> by lazy {
MutableLiveData()
}
/**
* SurfaceCallback implementation for handling the Android Auto surface lifecycle.
* Creates and manages the VirtualDisplay and Presentation for rendering Compose content.
*/
val mSurfaceCallback: SurfaceCallback = object : SurfaceCallback {
// Custom lifecycle owner for the virtual display
lateinit var lifecycleOwner: CustomLifecycleOwner
// Virtual display for rendering the map
lateinit var virtualDisplay: VirtualDisplay
// Presentation that hosts the Compose view
lateinit var presentation: Presentation
/**
* Called when the surface becomes available.
* Creates VirtualDisplay, initializes lifecycle, and sets up Compose rendering.
*/
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
synchronized(this@SurfaceRenderer) {
Log.i(TAG, "Surface available $surfaceContainer")
lifecycleOwner = CustomLifecycleOwner()
lifecycleOwner.performRestore(null)
// technically, we only really need any one of these instead of all 3
// i add them to be consistent with the actual lifecycle.
// add them to be consistent with the actual lifecycle.
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
@@ -120,18 +188,29 @@ class SurfaceRenderer(
}
}
/**
* Called when the visible area changes (e.g., due to UI elements appearing).
*/
override fun onVisibleAreaChanged(newVisibleArea: Rect) {
synchronized(this@SurfaceRenderer) {
visibleArea.value = newVisibleArea
}
}
/**
* Called when the stable area changes.
* Stable area is guaranteed not to change during scroll events.
*/
override fun onStableAreaChanged(newStableArea: Rect) {
synchronized(this@SurfaceRenderer) {
stableArea = newStableArea
}
}
/**
* Called when the surface is being destroyed.
* Cleans up resources and notifies lifecycle owner.
*/
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
synchronized(this@SurfaceRenderer) {
Log.i(TAG, "SurfaceRenderer destroyed")
@@ -144,13 +223,31 @@ class SurfaceRenderer(
}
}
/**
* Called when user scrolls the map .
*/
override fun onScroll(distanceX: Float, distanceY: Float) {
synchronized(this@SurfaceRenderer) {
viewStyle = ViewStyle.PAN_VIEW
if (distanceX != 0.0F) {
lastLocation.longitude += (distanceX / 1000) / cameraPosition.value!!.zoom
}
if (distanceY != 0.0F) {
lastLocation.latitude += (distanceY / 1000) / cameraPosition.value!!.zoom
}
val pos = Position(lastLocation.longitude, lastLocation.latitude)
updateCameraPosition( target = pos)
navigationSession.invalidateNavigationScreen()
}
}
/**
* Called when user scales (zooms) the map (not currently implemented).
*/
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
synchronized(this@SurfaceRenderer) {
Log.d(TAG, "onScale")
}
}
}
@@ -159,39 +256,69 @@ class SurfaceRenderer(
speed.value = 0F
}
fun onConnectionStateUpdated(connectionState: Int) {
when (connectionState) {
CarConnection.CONNECTION_TYPE_NATIVE -> ObjectBox.init(carContext)
}
}
/**
* Composable function that renders the map and navigation UI.
* Observes various LiveData sources and updates the map accordingly.
*/
@Composable
fun MapView() {
val darkMode =
settingsViewModel(carContext, viewModelStoreOwner).darkMode.collectAsState().value
val showBuildings =
settingsViewModel(carContext, viewModelStoreOwner).show3D.collectAsState().value
val position: CameraPosition? by cameraPosition.observeAsState()
val route: String? by routeData.observeAsState()
val traffic: Map<String, String>? by trafficData.observeAsState()
val speedCamera: String? by speedCameraData.observeAsState()
val paddingValues = getPaddingValues(height, viewStyle)
val cameraState = cameraState(paddingValues, position, tilt)
val baseStyle = BaseStyleModel().readStyle(carContext, darkMode, carContext.isDarkMode)
val dark = darkMode == DarkMode.DARK.ordinal
|| (darkMode == DarkMode.USE_CAR.ordinal && carContext.isDarkMode)
val baseStyle = remember {
mutableStateOf(BaseStyle.Uri(Constants.STYLE))
}
DarkMode(carContext, baseStyle)
MapLibre(carContext, cameraState, baseStyle, route, viewStyle)
ShowPosition(cameraState, position, paddingValues)
MapLibre(
cameraState,
baseStyle,
route,
traffic,
viewStyle,
speedCamera,
showBuildings
)
ShowPosition(cameraState, position, paddingValues, dark)
}
/**
* Composable that handles camera animations and navigation overlays.
* Displays speed indicator and navigation images during active navigation.
*/
@Composable
fun ShowPosition(
cameraState: CameraState,
position: CameraPosition?,
paddingValues: PaddingValues
paddingValues: PaddingValues,
darkMode: Boolean
) {
val cameraDuration =
duration(viewStyle == ViewStyle.PREVIEW, position!!.bearing, lastBearing)
duration(
viewStyle == ViewStyle.PREVIEW,
position!!.bearing,
lastBearing,
lastLocationUpdate
)
val currentSpeed: Float? by speed.observeAsState()
if (viewStyle == ViewStyle.VIEW) {
DrawNavigationImages(paddingValues, currentSpeed, routeModel.routeState.maxSpeed, width, height)
val maximumSpeed: Int? by maxSpeed.observeAsState()
val streetName: String? by street.observeAsState()
if (viewStyle == ViewStyle.VIEW || viewStyle == ViewStyle.PAN_VIEW) {
DrawNavigationImages(
paddingValues,
currentSpeed,
maximumSpeed!!,
width,
height,
streetName,
darkMode
)
}
LaunchedEffect(position, viewStyle) {
cameraState.animateTo(
@@ -202,42 +329,66 @@ class SurfaceRenderer(
tilt = tilt,
padding = paddingValues
),
duration = cameraDuration
duration = cameraDuration,
)
}
lastLocationUpdate = LocalDateTime.now()
}
override fun onCreate(owner: LifecycleOwner) {
CarConnection(carContext).type.observe(owner, ::onConnectionStateUpdated)
Log.i(TAG, "SurfaceRenderer created")
carContext.getCarService(AppManager::class.java)
.setSurfaceCallback(mSurfaceCallback)
}
/** Handles the map zoom-in and zoom-out events. */
/**
* Handles the map zoom-in and zoom-out events.
* Switches to PAN_VIEW mode and updates camera zoom level.
*/
fun handleScale(zoomSign: Int) {
synchronized(this) {
if (viewStyle == ViewStyle.VIEW) {
viewStyle = ViewStyle.PAN_VIEW
}
val newZoom = if (zoomSign < 0) {
cameraPosition.value!!.zoom - 1.0
cameraPosition.value!!.zoom - 0.2
} else {
cameraPosition.value!!.zoom + 1.0
cameraPosition.value!!.zoom + 0.2
}
if (viewStyle == ViewStyle.VIEW) {
tilt = calculateTilt(newZoom, tilt)
}
updateCameraPosition(
cameraPosition.value!!.bearing,
newZoom,
cameraPosition.value!!.target,
cameraPosition.value!!.target, tilt
)
}
}
fun updateLocation(location: Location) {
/**
* Updates the camera position based on current location.
* Calculates appropriate bearing, zoom, and maintains view style.
* Uses car orientation sensor if available, otherwise falls back to location bearing.
*/
fun updateLocation(location: Location, streetName: String) {
Log.d(TAG, "updateLocation Surface $location $streetName")
synchronized(this) {
street.value = streetName
if (viewStyle == ViewStyle.VIEW || viewStyle == ViewStyle.PAN_VIEW) {
val bearing = bearing(lastLocation, location, cameraPosition.value!!.bearing)
val bearing = if (carOrientation == 999F) {
if (location.hasBearing()) {
location.bearing.toDouble()
} else {
bearing(
lastLocation,
location,
cameraPosition.value!!.bearing
)
}
} else {
carOrientation.toDouble()
}
val zoom = if (viewStyle == ViewStyle.VIEW) {
calculateZoom(location.speed.toDouble())
} else {
@@ -246,39 +397,38 @@ class SurfaceRenderer(
updateCameraPosition(
bearing,
zoom,
Position(location.longitude, location.latitude)
Position(location.longitude, location.latitude), tilt
)
lastBearing = cameraPosition.value!!.bearing
lastLocation = location
speed.value = location.speed
if (!countDownTimerActive) {
countDownTimerActive = true
val mainThreadHandler = Handler(carContext.mainLooper)
val lastLocationTimer = lastLocation
checkUpdate(mainThreadHandler, lastLocationTimer)
}
}
}
}
private fun checkUpdate(
mainThreadHandler: Handler,
lastLocationTimer: Location
) {
mainThreadHandler.post {
object : CountDownTimer(3000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
countDownTimerActive = false
if (lastLocation.time - lastLocationTimer.time < 1500) {
speed.postValue(0F)
}
}
}.start()
}
/**
* Sets route data for active navigation and switches to VIEW mode.
*/
fun setRouteData(routeGeoJson: String) {
routeData.value = routeGeoJson
viewStyle = ViewStyle.VIEW
}
private fun updateCameraPosition(bearing: Double, zoom: Double, target: Position) {
/**
* Activates navigation View
*/
fun activateNavigationView() {
viewStyle = ViewStyle.VIEW
tilt = TILT
updateLocation(lastLocation, "")
}
/**
* Updates camera position with new bearing, zoom, and target.
* Posts update to LiveData for UI observation.
*/
fun updateCameraPosition(bearing: Double = 0.0, zoom: Double = cameraPosition.value!!.zoom ,
target: Position, tilt: Double = 0.0) {
synchronized(this) {
cameraPosition.postValue(
cameraPosition.value!!.copy(
bearing = bearing,
@@ -289,37 +439,80 @@ class SurfaceRenderer(
)
)
}
fun setRouteData() {
routeData.value = routeModel.route.routeGeoJson
viewStyle = ViewStyle.VIEW
}
fun setPreviewRouteData(routeModel: RouteModel) {
/**
* Updates traffic incident data on the map.
*/
fun setTrafficData(traffic: Map<String, String>) {
trafficData.value = traffic as MutableMap<String, String>?
}
/**
* Sets up route preview mode with overview camera position.
* Calculates appropriate zoom based on route distance.
*/
fun setPreviewRouteData(routeModel: RouteCarModel) {
viewStyle = ViewStyle.PREVIEW
with(routeModel) {
routeData.value = route.routeGeoJson
centerLocation = route.centerLocation
previewDistance = route.distance
routeData.value = curRoute.routeGeoJson
centerLocation = curRoute.centerLocation
previewDistance = curLeg.summary.distance
}
tilt = 0.0
updateCameraPosition(
0.0,
previewZoom(previewDistance),
Position(centerLocation.longitude, centerLocation.latitude)
previewZoom(centerLocation, previewDistance),
Position(centerLocation.longitude, centerLocation.latitude), 0.0
)
}
/**
* Sets up standard view mode with camera position.
* Calculates appropriate zoom
*/
fun setStandardView() {
if (!navigation) {
setRouteData("")
}
viewStyle = ViewStyle.VIEW
val zoom = calculateZoom(0.0)
tilt = calculateTilt(zoom, tilt)
updateCameraPosition(
tilt = tilt,
zoom = zoom,
target = Position(lastLocation.longitude, lastLocation.latitude)
)
}
/**
* Displays a specific location (e.g., amenity/POI) on the map.
*/
fun setCategories(location: Location, route: String) {
synchronized(this) {
viewStyle = ViewStyle.AMENITY_VIEW
routeData.value = route
tilt = 0.0
updateCameraPosition(
0.0,
12.0,
target = Position(location.longitude, location.latitude)
zoom = 14.0,
target = Position(location.longitude, location.latitude),
tilt = tilt
)
}
}
fun setCategoryLocation(location: Location, category: String) {
/**
* Updates current speed for display.
*/
fun updateCarSpeed(newSpeed: Float) {
speed.value = newSpeed
}
/**
* Centers the map on a specific POI location.
*/
fun setCategoryLocation(location: Location) {
viewStyle = ViewStyle.AMENITY_VIEW
cameraPosition.postValue(
cameraPosition.value!!.copy(
@@ -327,15 +520,4 @@ class SurfaceRenderer(
)
)
}
companion
object {
private const val TAG = "MapRenderer"
}
}
enum class ViewStyle {
VIEW, PREVIEW, PAN_VIEW, AMENITY_VIEW
}
@@ -0,0 +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
class TextToSpeechManager(private val carContext: Context) {
private var textToSpeech: TextToSpeech? = null
@Volatile private 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) {
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.e("TTS", "Initialization Failed")
}
}
}
fun speak(text: String) {
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)
}
}
fun cleanup() {
if (initialized) {
textToSpeech?.stop()
textToSpeech?.shutdown()
initialized = false
}
}
}
@@ -47,6 +47,7 @@ import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@@ -244,8 +245,8 @@ private fun rememberLocationSource(locationState: Location): GeoJsonSource {
buildJsonObject {
put("accuracy", location.accuracy)
put("bearing", location.bearing)
//put("bearingAccuracy", location.bearingAccuracy)
//put("age", location.timestamp.elapsedNow().inWholeNanoseconds)
put("bearingAccuracy", location.hasBearingAccuracy())
put("age", location.time.absoluteValue)
},
)
)
@@ -1,10 +1,8 @@
package com.kouros.navigation.car.map
import android.annotation.SuppressLint
import android.location.Location
import android.content.Context
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,11 +10,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
@@ -25,25 +23,28 @@ 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
import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.SHOW_THREED_BUILDING
import com.kouros.navigation.data.NavigationColor
import com.kouros.navigation.data.NavigationColorDark
import com.kouros.navigation.data.NavigationColorLight
import com.kouros.navigation.data.RouteColor
import com.kouros.navigation.data.SpeedColor
import com.kouros.navigation.utils.NavigationUtils.getBooleanKeyValue
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.utils.isMetricSystem
import com.kouros.navigation.utils.location
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.ast.Expression
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.exponential
import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.interpolate
import org.maplibre.compose.expressions.dsl.zoom
import org.maplibre.compose.expressions.value.ColorValue
import org.maplibre.compose.layers.Anchor
import org.maplibre.compose.layers.FillLayer
import org.maplibre.compose.layers.LineLayer
@@ -52,6 +53,7 @@ import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
@@ -61,6 +63,7 @@ import org.maplibre.compose.sources.getBaseSource
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position
import kotlin.time.Duration.Companion.seconds
@Composable
@@ -76,20 +79,22 @@ fun cameraState(
latitude = position!!.target.latitude,
longitude = position.target.longitude
),
zoom = 15.0,
zoom = position.zoom,
tilt = tilt,
padding = padding
padding = padding,
)
)
}
@Composable
fun MapLibre(
context: Context,
cameraState: CameraState,
baseStyle: MutableState<BaseStyle.Uri>,
baseStyle: BaseStyle.Json,
route: String?,
viewStyle: ViewStyle
traffic: Map<String, String>?,
viewStyle: ViewStyle,
speedCameras: String? = "",
showBuildings: Boolean
) {
MaplibreMap(
options = MapOptions(
@@ -97,25 +102,30 @@ fun MapLibre(
OrnamentOptions(isScaleBarEnabled = false)
),
cameraState = cameraState,
baseStyle = baseStyle.value
baseStyle = baseStyle,
) {
getBaseSource(id = "openmaptiles")?.let { tiles ->
if (!getBooleanKeyValue(context = context, SHOW_THREED_BUILDING)) {
if (!showBuildings) {
BuildingLayer(tiles)
}
if (viewStyle == ViewStyle.AMENITY_VIEW) {
val lastLocation = location(cameraState.position.target.longitude, cameraState.position.target.latitude)
Puck(cameraState, lastLocation)
AmenityLayer(route)
} else {
RouteLayer(route)
RouteLayer(route, traffic!!)
//RouteLayerPoint(route )
}
SpeedCameraLayer(speedCameras)
}
//Puck(cameraState, lastLocation)
}
}
@Composable
fun RouteLayer(routeData: String?) {
if (routeData != null && routeData.isNotEmpty()) {
fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
LineLayer(
id = "routes-casing",
@@ -135,34 +145,139 @@ fun RouteLayer(routeData: String?) {
id = "routes",
source = routes,
color = const(RouteColor),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.7.dp),
6 to const(1.0.dp),
7 to const(2.4.dp),
20 to const(26.dp),
),
)
}
trafficData.forEach {
val traffic = rememberGeoJsonSource(GeoJsonData.JsonString(it.value))
LineLayer(
id = "traffic-${it.key}-casing",
source = traffic,
color = const(Color.White),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.7.dp),
7 to const(1.75.dp),
20 to const(22.dp),
6 to const(0.6.dp),
7 to const(1.8.dp),
20 to const(20.dp),
),
)
LineLayer(
id = "traffic-${it.key}",
source = traffic,
color = trafficColor(it.key),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.5.dp),
7 to const(1.6.dp),
20 to const(18.dp),
),
)
}
}
@Composable
fun RouteLayerPoint(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
val img = image(painterResource(R.drawable.ic_favorite_filled_white_24dp), drawAsSdf = true)
SymbolLayer(
id = "point-layer",
source = routes,
iconOpacity = const(2.0f),
iconColor = const(Color.Red),
iconImage = img,
iconSize =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4f),
6 to const(0.6f),
7 to const(0.8f),
20 to const(1.0f),
),
)
}
}
fun trafficColor(key: String): Expression<ColorValue> {
when (key) {
"queuing" -> return const(Color(0xFFC46E53))
"slow" -> return const(Color(0xFFC43E3E))
"stationary" -> return const(Color(0xFF910A0A))
"heavy" -> return const(Color(0xFF6B0404))
"roadworks" -> return const(Color(0xFF443506))
}
return const(Color.Blue)
}
@Composable
fun AmenityLayer(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
var color = const(Color.Red)
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_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)
}
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
SymbolLayer(
id = "amenity-layer",
source = routes,
iconImage = img,
iconColor = color,
iconOpacity = const(2.0f),
iconSize =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.7f),
6 to const(1.0f),
7 to const(2.0f),
20 to const(4f),
),
)
}
}
@Composable
fun AmenityLayer(routeData: String?) {
if (routeData != null && routeData.isNotEmpty()) {
val color = if (routeData.contains(Constants.PHARMACY)) {
const(Color.Red)
} else {
const(Color.Green)
}
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
fun SpeedCameraLayer(speedCameras: String?) {
if (!speedCameras.isNullOrEmpty()) {
val color = const(Color.Red)
val cameraSource = rememberGeoJsonSource(GeoJsonData.JsonString(speedCameras))
SymbolLayer(
id = "amenity-layer",
source = routes,
iconImage = image(painterResource(R.drawable.ev_station_48px), drawAsSdf = true),
id = "speed-camera-layer",
source = cameraSource,
iconImage = image(painterResource(R.drawable.speed_camera_24px), drawAsSdf = true),
iconColor = color,
iconSize = const(3.0f),
iconSize =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.7f),
6 to const(1.0f),
7 to const(2.0f),
20 to const(4f),
),
)
}
}
@@ -185,35 +300,92 @@ fun DrawNavigationImages(
speed: Float?,
maxSpeed: Int,
width: Int,
height: Int
height: Int,
streetName: String?,
darkMode: Boolean,
) {
NavigationImage(padding, width, height)
CurrentSpeed(width, height, speed)
NavigationImage(padding, width, height, streetName, darkMode)
if (speed != null) {
CurrentSpeed(width, height, speed, maxSpeed)
}
if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) {
MaxSpeed(width, height, maxSpeed)
}
//DebugInfo(width, height, lat!!)
}
@Composable
fun NavigationImage(padding: PaddingValues, width: Int, height: Int) {
val imageSize = (height / 6)
val color = remember { NavigationColor }
fun NavigationImage(
padding: PaddingValues,
width: Int,
height: Int,
streetName: String?,
darkMode: Boolean
) {
val imageSize = (height / 8)
val navigationColor = if (darkMode)
remember { NavigationColorDark }
else
remember { NavigationColorLight }
val textMeasurerStreet = rememberTextMeasurer()
val street = streetName.toString()
val styleStreet = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (darkMode) Color.White else navigationColor,
)
val textLayoutStreet = remember(street) {
textMeasurerStreet.measure(street, styleStreet, overflow = TextOverflow.Ellipsis)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) {
Canvas(
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
) {
scale(scaleX = 1f, scaleY = 0.7f) {
drawCircle(Color.DarkGray.copy(alpha = 0.3f))
drawCircle(navigationColor.copy(alpha = 0.3f))
}
}
Icon(
painter = painterResource(id = R.drawable.navigation_48px),
"Navigation",
tint = color.copy(alpha = 0.7f),
modifier = Modifier.size(imageSize.dp, imageSize.dp)
tint = navigationColor.copy(alpha = 0.7f),
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
.scale(scaleX = 1f, scaleY = 0.7f),
)
Canvas(
modifier = Modifier
.size(textLayoutStreet.size.width.dp, textLayoutStreet.size.height.dp * 6 )
) {
if (street.isNotEmpty()) {
val topLeftX = center.x - textLayoutStreet.size.width / 2
val topLeftY = center.y + textLayoutStreet.size.height
drawRoundRect(
topLeft = Offset(
x = topLeftX ,
y = topLeftY,
),
color = if (darkMode) NavigationColorLight else Color.White,
cornerRadius = CornerRadius(x = 10f, y = 10f),
)
drawText(
textMeasurer = textMeasurerStreet,
text = street,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = styleStreet,
topLeft = Offset(
x = topLeftX,
y = topLeftY + 10,
)
)
}
}
}
}
@@ -221,9 +393,11 @@ fun NavigationImage(padding: PaddingValues, width: Int, height: Int) {
private fun CurrentSpeed(
width: Int,
height: Int,
speed: Float?
curSpeed: Float,
maxSpeed: Int
) {
val radius = 32
val radius = 34
Box(
modifier = Modifier
.padding(
@@ -234,8 +408,11 @@ private fun CurrentSpeed(
) {
val textMeasurerSpeed = rememberTextMeasurer()
val textMeasurerKm = rememberTextMeasurer()
val speed = (speed!! * 3.6).toInt().toString()
val kmh = "km/h"
val speed = if (isMetricSystem()) (curSpeed * 3.6).toInt().toString() else (curSpeed * 3.6 * 0.6214).toInt().toString()
val kmh = if (isMetricSystem()) "km/h" else "mph"
val styleSpeed = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
@@ -245,10 +422,10 @@ private fun CurrentSpeed(
fontSize = 12.sp,
color = Color.White,
)
val textLayoutSpeed = remember(speed) {
val textLayoutSpeed = remember(speed, maxSpeed) {
textMeasurerSpeed.measure(speed, styleSpeed)
}
val textLayoutKm = remember(kmh) {
val textLayoutKm = remember(kmh, maxSpeed) {
textMeasurerSpeed.measure(kmh, styleKm)
}
Canvas(modifier = Modifier.fillMaxSize()) {
@@ -271,7 +448,7 @@ private fun CurrentSpeed(
)
drawText(
textMeasurer = textMeasurerKm,
text = "km/h",
text = kmh,
style = styleKm,
topLeft = Offset(
x = center.x - textLayoutKm.size.width / 2,
@@ -288,7 +465,7 @@ private fun MaxSpeed(
height: Int,
maxSpeed: Int,
) {
val radius = 20
val radius = 24
Box(
modifier = Modifier
.padding(
@@ -338,25 +515,50 @@ private fun MaxSpeed(
}
@Composable
fun DarkMode(context: Context, baseStyle: MutableState<BaseStyle.Uri>) {
val darkMode = getIntKeyValue(context, Constants.DARK_MODE_SETTINGS)
if (darkMode == 0) {
baseStyle.value = BaseStyle.Uri(Constants.STYLE)
fun DebugInfo(
width: Int,
height: Int,
latitude: Double,
) {
Box(
modifier = Modifier
.padding(
start = 20.dp,
top = 0.dp
),
contentAlignment = Alignment.CenterStart
) {
val textMeasurerLocation = rememberTextMeasurer()
val styleSpeed = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
)
val textLayoutLocation = remember(latitude.toString()) {
textMeasurerLocation.measure(latitude.toString(), styleSpeed)
}
if (darkMode == 1) {
baseStyle.value = BaseStyle.Uri(Constants.STYLE_DARK)
Canvas(modifier = Modifier.fillMaxSize()) {
drawText(
textMeasurer = textMeasurerLocation,
text = latitude.toString(),
style = styleSpeed,
topLeft = Offset(
x = center.x - textLayoutLocation.size.width / 2,
y = center.y - textLayoutLocation.size.height / 2,
)
)
}
if (darkMode == 2) {
baseStyle.value =
(if (isSystemInDarkTheme()) BaseStyle.Uri(Constants.STYLE_DARK) else BaseStyle.Uri(
Constants.STYLE
))
}
}
fun getPaddingValues(height: Int, viewStyle: ViewStyle): PaddingValues {
return when (viewStyle) {
ViewStyle.VIEW -> PaddingValues(start = 50.dp, top = distanceFromTop(height).dp)
ViewStyle.VIEW, ViewStyle.PAN_VIEW -> PaddingValues(
start = 100.dp,
top = distanceFromTop(height).dp
)
ViewStyle.PREVIEW -> PaddingValues(start = 150.dp, bottom = 0.dp)
else -> PaddingValues(start = 250.dp, bottom = 0.dp)
}
@@ -377,6 +579,7 @@ fun Puck(cameraState: CameraState, location: Location) {
locationState = location,
cameraState = cameraState,
accuracyThreshold = 10f,
oldLocationThreshold = 2.seconds,
showBearing = false,
sizes = LocationPuckSizes(dotRadius = 10.dp),
colors = LocationPuckColors(
@@ -386,19 +589,4 @@ fun Puck(cameraState: CameraState, location: Location) {
)
}
@Composable
fun PuckState(cameraState: CameraState, userLocationState: UserLocationState) {
LocationPuck(
idPrefix = "user-location1",
locationState = userLocationState,
cameraState = cameraState,
accuracyThreshold = 10f,
showBearing = false,
sizes = LocationPuckSizes(dotRadius = 10.dp),
colors = LocationPuckColors(
dotFillColorCurrentLocation = Color.Cyan,
accuracyStrokeColor = Color.Green
)
)
}
@@ -1,45 +0,0 @@
package com.kouros.navigation.car.navigation
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.model.Action
import androidx.car.app.model.Alert
import androidx.car.app.model.AlertCallback
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.OnClickListener
import androidx.core.graphics.drawable.IconCompat
import com.kouros.android.cars.carappservice.R
class NavigationMessage (private var carContext: CarContext) {
private fun createToastAction(
@StringRes titleRes: Int, @StringRes toastStringRes: Int,
flags: Int
): Action {
return Action.Builder()
.setOnClickListener { showToast(toastStringRes) }
.setTitle(createCarText(titleRes))
.setFlags(flags)
.build()
}
fun showToast(@StringRes toastStringRes: Int) {
CarToast.makeText(
carContext,
carContext.getString(toastStringRes),
CarToast.LENGTH_SHORT
)
.show()
}
fun createCarText(@StringRes stringRes: Int): CarText {
return CarText.create(carContext.getString(stringRes))
}
fun createCarIcon(@DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()
}
}
@@ -0,0 +1,349 @@
package com.kouros.navigation.car.navigation
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.location.Location
import android.location.LocationManager
import android.os.Binder
import android.os.IBinder
import android.text.TextUtils
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.model.Distance.UNIT_METERS
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.NavigationManagerCallback
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import com.kouros.navigation.car.DeviceLocationManagerService
import com.kouros.navigation.car.screen.NavigationType
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Place
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlin.collections.copy
import kotlin.compareTo
class NavigationService : Service() {
val TAG: String = "NavigationService"
val DEEP_LINK_ACTION: String = ("com.kouros.navigation.car.navigation"
+ ".NavigationDeepLinkAction")
val channelId: String = "NavigationServiceChannel"
/** The identifier for the navigation notification displayed for the foreground service. */
val NAV_NOTIFICATION_ID: Int = 87356325
/** The identifier for the non-navigation notifications, such as a traffic accident warning. */
val NOTIFICATION_ID: Int = 71653346
// Constants for location broadcast
val PACKAGE_NAME: String =
"androidx.car.app.sample.navigation.common.nav.navigationservice"
val EXTRA_STARTED_FROM_NOTIFICATION: String = PACKAGE_NAME + ".started_from_notification"
val CANCEL_ACTION: String = "CANCEL"
private var notificationManager: NotificationManager? = null
private var carContext: CarContext? = null
var autoDriveEnabled = false
val simulation = Simulation()
private lateinit var listener: Listener
// Model for managing route state and navigation logic for Android Auto
var routeModel = RouteCarModel()
// Manages device GPS location updates
lateinit var deviceLocationManager: DeviceLocationManagerService
var currentLocation = location(0.0, 0.0)
lateinit var navigationViewModel: NavigationViewModel
private lateinit var navigationManager: NavigationManager
private var navigationManagerInitialized = false
var binder: IBinder = LocalBinder()
/** A listener for the navigation state changes. */
interface Listener {
/** Callback called when the navigation state changes. */
fun navigationStateChanged(
isNavigating: Boolean,
isRerouting: Boolean,
hasArrived: Boolean,
destinations: MutableList<Destination>,
steps: MutableList<Step>,
destinationTravelEstimate: TravelEstimate,
stepTravelEstimate: TravelEstimate,
stepRemainingDistance: Distance,
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?,
backGroundColor: CarColor
)
fun updateServiceLocation(location: Location)
}
/**
* Class used for the client Binder. Since this service runs in the same process as its clients,
* we don't need to deal with IPC.
*/
inner class LocalBinder : Binder() {
val service: NavigationService
get() = this@NavigationService
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onCreate() {
Log.i(TAG, "In onCreate()");
createNotificationChannel();
}
override fun onBind(p0: Intent?): IBinder {
Log.d(TAG, "in onBind")
return binder
}
override fun onUnbind(intent: Intent): Boolean {
Log.d(TAG, "in UnBind")
if (!routeModel.isNavigating()) {
Log.d(TAG, "Stopping location updates")
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
}
return true
}
override fun onDestroy() {
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
Log.i(TAG, "In onDestroy()");
}
private fun createNotificationChannel() {
val serviceChannel = NotificationChannel(
"CHANNEL_ID",
"Location Service Channel",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
/** Sets the [CarContext] to use while the service is connected. */
@RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun setCarContext(
carContext: CarContext,
listener: Listener
) {
Log.d(TAG, "in setCarContext")
this.carContext = carContext
navigationViewModel = getViewModel(carContext)
this.listener = listener
deviceLocationManager = DeviceLocationManagerService(
carContext = carContext,
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
updateLocation(location)
}
)
deviceLocationManager.startLocationUpdates()
navigationManagerInitialized = true
navigationManager =
carContext.getCarService(NavigationManager::class.java)
navigationManager.setNavigationManagerCallback(object : NavigationManagerCallback {
override fun onAutoDriveEnabled() {
Log.d(TAG, "onAutoDriveEnabled")
// Called when the app should simulate navigation (e.g., for testing)
deviceLocationManager.stopLocationUpdates()
autoDriveEnabled = true
simulation()
CarToast.makeText(carContext, "Auto drive enabled", CarToast.LENGTH_LONG)
.show()
}
private fun simulation() {
simulation.gpxSimulation {
listener.updateServiceLocation(it)
}
}
override fun onStopNavigation() {
// Called when the user stops navigation in the car screen
// Stop turn-by-turn logic and clean up
stopNavigation()
if (autoDriveEnabled) {
deviceLocationManager.startLocationUpdates()
}
}
})
// Uncomment if navigating
// mNavigationManager.navigationStarted();
}
/** Clears the currently used {@link CarContext}. */
fun clearCarContext() {
Log.i(TAG, "clearContext");
carContext = null;
navigationManager.clearNavigationManagerCallback();
}
/** Starts navigation. */
fun startNavigation(route: String, destination: Place) {
Log.i(TAG, "Starting Navigation")
startService(Intent(applicationContext, NavigationService::class.java))
routeModel.navState = routeModel.navState.copy(destination = destination)
routeModel.navState = routeModel.navState.copy(routingEngine = 2)
routeModel.startNavigation(route)
if (routeModel.isNavigating()) {
routeModel.updateLocation(currentLocation, navigationViewModel)
listener.navigationStateChanged(
isNavigating = true,
isRerouting = false,
hasArrived = false,
destinations = mutableListOf(routeModel.getDestination()),
destinationTravelEstimate = routeModel.getTravelEstimateTrip(carContext!!),
stepTravelEstimate = routeModel.getTravelEstimateStep(carContext!!),
steps = routeModel.getSteps(carContext!!),
stepRemainingDistance = routeModel.getDistance(),
shouldShowNextStep = false,
shouldShowLanes = false,
junctionImage = null,
backGroundColor = routeModel.backGroundColor()
)
}
}
/** Starts navigation. */
fun stopNavigation() {
if (autoDriveEnabled) {
autoDriveEnabled = false
}
if (navigationManagerInitialized)
navigationManager.navigationEnded()
listener.navigationStateChanged(
isNavigating = false,
isRerouting = false,
hasArrived = false,
destinations = emptyList<Destination>().toMutableList(),
steps = emptyList<Step>().toMutableList(),
destinationTravelEstimate = routeModel.travelEstimate(carContext!!, 0.0, 0),
stepTravelEstimate = routeModel.travelEstimate(carContext!!, 0.0, 0),
stepRemainingDistance = Distance.create(0.0, UNIT_METERS),
shouldShowNextStep = false,
shouldShowLanes = false,
junctionImage = null,
backGroundColor = CarColor.BLUE
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
fun updateLocation(location: Location) {
Log.d(TAG, "updateLocation")
currentLocation = location
if (routeModel.isNavigating()) {
routeModel.updateLocation(location, navigationViewModel)
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
listener.updateServiceLocation(snappedLocation)
checkArrival()
updateNavigationScreen( 0)
} else {
listener.updateServiceLocation(location)
}
}
fun isNavigating(): Boolean {
return routeModel.isNavigating()
}
fun updateNavigationScreen(distanceMode: Int) {
if (routeModel.isNavigating() && routeModel.navState.destination.name.isEmpty()
&& routeModel.navState.destination.street.isEmpty()
) {
return
}
listener.navigationStateChanged(
isNavigating = routeModel.isNavigating(),
isRerouting = false,
hasArrived = routeModel.isArrival(),
destinations = mutableListOf(routeModel.getDestination()),
destinationTravelEstimate = routeModel.getTravelEstimateTrip(carContext!!),
stepTravelEstimate = routeModel.getTravelEstimateStep(carContext!!),
steps = routeModel.getSteps(carContext!!),
stepRemainingDistance = routeModel.getDistance(),
shouldShowNextStep = false,
shouldShowLanes = false,
junctionImage = null,
backGroundColor = routeModel.backGroundColor()
)
/**
* Updates the trip information and notifies the listener with a new Trip object.
* This includes destination name, address, travel estimate, and loading status.
*/
val tripBuilder = Trip.Builder()
tripBuilder.addDestination(
routeModel.getDestination(),
routeModel.getTravelEstimateTrip(carContext!!)
)
tripBuilder.setLoading(false)
tripBuilder.setCurrentRoad(routeModel.getDestination().name.toString())
tripBuilder.addStep(routeModel.getSteps(carContext!!).first(), routeModel.getTravelEstimateStep(carContext!!))
navigationManager.updateTrip(tripBuilder.build())
}
/**
* Checks for arrival
*/
fun checkArrival() {
if (routeModel.isArrival()
&& routeModel.routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
stopNavigation()
routeModel.navState = routeModel.navState.copy(arrived = true)
}
}
}
@@ -1,22 +1,9 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car.navigation
import android.text.SpannableString
import androidx.annotation.DrawableRes
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.annotation.StringRes
import androidx.car.app.AppManager
import androidx.car.app.CarContext
@@ -29,87 +16,201 @@ import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.DateTimeWithZone
import androidx.car.app.model.Distance
import androidx.car.app.model.DurationSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.car.screen.createCarIcon
import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.route.ManeuverType
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.formattedDistance
import java.time.Duration
import java.util.TimeZone
import java.util.concurrent.TimeUnit
/** A class that provides models for the routing demos. */
class RouteCarModel() : RouteModel() {
class RouteCarModel : RouteModel() {
/** Returns the current [Step] with information such as the cue text and images. */
fun currentStep(carContext: CarContext): Step {
val stepData = currentStep()
val currentStepCueWithImage: SpannableString =
createString(stepData.instruction)
val maneuver = Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
if (stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW.ordinal
|| stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW.ordinal
) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
val step =
Step.Builder(currentStepCueWithImage)
.setManeuver(
Maneuver.Builder(stepData.maneuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
.build()
step.setRoad(navState.destination.street)
if (stepData.lane.isNotEmpty()) {
val lanesAdded = addLanes(carContext, step, stepData)
if (lanesAdded) {
maneuver.setIcon(createCarIcon(carContext, R.drawable.empty))
}
}
step.setManeuver(
maneuver.build()
)
.setRoad(routeState.destination.street!!)
.build()
return step
return step.build()
}
/** Returns the next [Step] with information such as the cue text and images. */
fun nextStep(carContext: CarContext): Step? {
fun nextStep(carContext: CarContext): Step {
val stepData = nextStep()
val currentStepCueWithImage: SpannableString =
createString(stepData.instruction)
val maneuver = Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
if (stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW.ordinal
|| stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW.ordinal
) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
val step =
Step.Builder(currentStepCueWithImage)
.setManeuver(
Maneuver.Builder(stepData.maneuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
.build()
maneuver.build()
)
.build()
return step
}
fun travelEstimate(carContext: CarContext): TravelEstimate {
val timeLeft = travelLeftTime()
fun travelEstimateTrip(carContext: CarContext, distanceMode: Int): TravelEstimate {
return travelEstimate(carContext, routeCalculator.travelLeftTime(), distanceMode)
}
fun travelEstimateStep(carContext: CarContext, distanceMode: Int): TravelEstimate {
return travelEstimate(carContext, routeCalculator.travelStepLeftTime(), distanceMode)
}
fun travelEstimate(carContext: CarContext, timeLeft: Double, distanceMode: Int): TravelEstimate {
val timeToDestinationMillis =
TimeUnit.SECONDS.toMillis(timeLeft.toLong())
val leftDistance = travelLeftDistance()
val displayUnit = if (leftDistance > 1.0) {
Distance.UNIT_KILOMETERS
} else {
Distance.UNIT_METERS
}
val arivalTime = DateTimeWithZone.create(
arrivalTime(),
TimeZone.getTimeZone("Europe/Berlin")
val distance = formattedDistance(distanceMode, routeCalculator.travelLeftDistance())
val arrivalTime = DateTimeWithZone.create(
routeCalculator.arrivalTime(),
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.
arivalTime
arrivalTime
)
.setRemainingTimeSeconds(
TimeUnit.MILLISECONDS.toSeconds(
timeToDestinationMillis
)
)
.setRemainingTimeColor(CarColor.YELLOW)
.setRemainingDistanceColor(CarColor.RED)
.setRemainingTimeColor(CarColor.GREEN)
.setRemainingDistanceColor(CarColor.BLUE)
if (traffic > 0) {
travelBuilder.setTripText(createDelay(traffic))
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.traffic_jam_48px))
}
if (routeState.travelMessage.isNotEmpty()) {
if (navState.travelMessage.isNotEmpty()) {
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
travelBuilder.setTripText(CarText.create(routeState.travelMessage))
travelBuilder.setTripText(CarText.create(navState.travelMessage))
}
return travelBuilder.build()
}
fun getSteps(carContext: CarContext): MutableList<Step> {
val steps = mutableListOf<Step>()
steps.add(currentStep(carContext))
if (navState.nextStep) {
steps.add(nextStep(carContext = carContext))
}
return steps
}
fun getDistance(): Distance {
val distance =
formattedDistance(0, routeCalculator.leftStepDistance())
return Distance.create(distance.first, distance.second)
}
fun getTravelEstimateTrip(carContext: CarContext): TravelEstimate {
return travelEstimateTrip(carContext, 0)
}
fun getTravelEstimateStep(carContext: CarContext): TravelEstimate {
return travelEstimateStep(carContext, 0)
}
fun getDestination(): Destination {
return Destination.Builder()
.setName(navState.destination.name)
.setAddress(navState.destination.street)
.build()
}
private fun createDelay(delay: Int): CarText {
val delayBuilder = SpannableStringBuilder()
delayBuilder.append(
" ",
DurationSpan.create(Duration.ofMinutes(delay.toLong())),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
delayBuilder.setSpan(
ForegroundCarColorSpan.create(CarColor.RED),
0,
1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
return CarText.Builder(delayBuilder)
.build()
}
fun addLanes(carContext: CarContext, step: Step.Builder, stepData: StepData) : Boolean {
var laneImageAdded = false
stepData.lane.forEach {
if (it.indications.isNotEmpty()) {
val sorted = it.indications.sorted()
var direction = ""
sorted.forEach { it2 ->
direction = if (direction.isEmpty()) {
it2.trim()
} else {
"${direction}_${it2.trim()}"
}
}
val laneDirection = navState.iconMapper.addLanes(direction, stepData)
if (laneDirection != LaneDirection.SHAPE_UNKNOWN) {
if (!laneImageAdded) {
step.setLanesImage(createCarIcon(navState.iconMapper.createLaneIcon(carContext, stepData)))
laneImageAdded = true
}
val laneType =
Lane.Builder()
.addDirection(LaneDirection.create(laneDirection, it.valid))
.build()
step.addLane(laneType)
}
}
}
return laneImageAdded
}
fun createString(
text: String
): SpannableString {
@@ -121,33 +222,40 @@ class RouteCarModel() : RouteModel() {
return CarText.create(carContext.getString(stringRes))
}
fun createCarIcon(carContext: CarContext, @DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()
fun createCarIcon(iconCompat: IconCompat): CarIcon {
return CarIcon.Builder(iconCompat).build()
}
fun showSpeedCamera(carContext: CarContext, distance: Double, maxSpeed: String?) {
carContext.getCarService<AppManager?>(AppManager::class.java)
.showAlert(createAlert(carContext, distance, maxSpeed))
fun showSpeedCamera(carContext: CarContext, distance: Double, maxSpeed: String) {
carContext.getCarService(AppManager::class.java)
.showAlert(
createAlert(
carContext,
maxSpeed,
createCarIcon(carContext, R.drawable.speed_camera_24px)
)
)
}
fun createAlert(carContext: CarContext, distance: Double, maxSpeed: String?): Alert {
fun createAlert(
carContext: CarContext,
maxSpeed: String?,
icon: CarIcon
): Alert {
val title = createCarText(carContext, R.string.speed_camera)
val subtitle = CarText.create(maxSpeed!!)
val icon = CarIcon.ALERT
val dismissAction: Action = createToastAction(
carContext,
R.string.speed_camera, R.string.exit_action_title,
R.string.exit_action_title, R.string.exit_action_title,
FLAG_DEFAULT
)
return Alert.Builder( /* alertId: */0, title, /* durationMillis: */10000)
return Alert.Builder( /* alertId: */0, title, /* durationMillis: */5000)
.setSubtitle(subtitle)
.setIcon(icon)
.addAction(dismissAction).setCallback(object : AlertCallback {
override fun onCancel(reason: Int) {
}
override fun onDismiss() {
}
}).build()
@@ -164,4 +272,17 @@ class RouteCarModel() : RouteModel() {
.setFlags(flags)
.build()
}
fun backGroundColor(): CarColor {
return if (isNavigating()) {
when (route.currentStep().countryCode) {
"DEU", "FRA", "AUT", "POL", "BEL", "NLD", "ESP", "PRT", "CZE", "SVK", "BGR", "HUN" -> CarColor.BLUE
else -> {
CarColor.GREEN
}
}
} else {
CarColor.GREEN
}
}
}
@@ -0,0 +1,198 @@
package com.kouros.navigation.car.navigation
import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import android.util.Log
import androidx.lifecycle.LifecycleCoroutineScope
import com.kouros.data.BuildConfig
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.tomtom.TomTomRepository
import io.ticofab.androidgpxparser.parser.GPXParser
import io.ticofab.androidgpxparser.parser.domain.Gpx
import io.ticofab.androidgpxparser.parser.domain.TrackSegment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.joda.time.DateTime
class Simulation {
private var simulationJob: Job? = null
fun startSimulation(
routeModel: RouteCarModel,
lifecycleScope: LifecycleCoroutineScope,
updateLocation: (Location) -> Unit
) {
if (routeModel.navState.route.isRouteValid()) {
if (BuildConfig.DEBUG) {
gpxSimulation(routeModel, lifecycleScope, updateLocation)
//gpxSimulation(updateLocation)
//currentSimulation(routeModel, lifecycleScope, updateLocation)
} else {
currentSimulation(routeModel, lifecycleScope, updateLocation)
}
return
}
}
private fun currentSimulation(
routeModel: RouteCarModel,
lifecycleScope: LifecycleCoroutineScope,
updateLocation: (Location) -> Unit
) {
val points = routeModel.curRoute.waypoints
if (points.isEmpty()) return
simulationJob?.cancel()
var lastLocation = Location(LocationManager.FUSED_PROVIDER)
var curBearing = 0f
simulationJob = lifecycleScope.launch {
for ((index, point) in points.withIndex()) {
if (index >= 0) {
val fakeLocation = Location(LocationManager.FUSED_PROVIDER).apply {
latitude = point[1]
longitude = point[0]
bearing = curBearing
speedAccuracyMetersPerSecond = 1.0f // ~1 m/s
speed = 5.0f
time = System.currentTimeMillis()
elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
}
curBearing = lastLocation.bearingTo(fakeLocation)
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 1 second)
delay(1000)
lastLocation = fakeLocation
}
}
// routeModel.stopNavigation()
}
}
private fun gpxSimulation(
routeModel: RouteCarModel,
lifecycleScope: LifecycleCoroutineScope,
updateLocation: (Location) -> Unit
) {
var route = ""
simulationJob?.cancel()
runBlocking {
simulationJob = launch(Dispatchers.IO) {
route = TomTomRepository().fetchUrl(
"https://kouros-online.de/vh.gpx",
false
)
}
simulationJob?.join()
}
simulationJob?.cancel()
simulationJob = lifecycleScope.launch() {
var lastLocation = Location(LocationManager.FUSED_PROVIDER)
var curBearing = 0f
val parser = GPXParser()
val parsedGpx: Gpx? =
parser.parse(route.byteInputStream())
parsedGpx?.let {
val tracks = parsedGpx.tracks
tracks.forEach { tr ->
val segments: MutableList<TrackSegment?>? = tr.trackSegments
segments!!.forEach { seg ->
var lastTime = DateTime.now()
seg!!.trackPoints.forEach { p ->
val ext = p.extensions
var curSpeed = 0F
if (ext != null) {
curSpeed = ext.speed.toFloat()
}
val duration = p.time.millis - lastTime.millis
val fakeLocation = Location(LocationManager.FUSED_PROVIDER).apply {
latitude = p.latitude
longitude = p.longitude
speedAccuracyMetersPerSecond = 1.0f // ~1 m/s
speed = curSpeed
time = System.currentTimeMillis()
elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
}
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 1 second)
if (duration > 100) {
// delay(duration / 4)
}
delay(500)
lastTime = p.time
lastLocation = fakeLocation
}
}
}
}
routeModel.stopNavigation()
}
}
fun stopSimulation() {
simulationJob?.cancel()
}
fun gpxSimulation(
updateLocation: (Location) -> Unit
) {
Runnable {
var route = ""
simulationJob?.cancel()
runBlocking {
simulationJob = launch(Dispatchers.IO) {
route = TomTomRepository().fetchUrl(
"https://kouros-online.de/vh.gpx",
false
)
}
simulationJob?.join()
}
simulationJob?.cancel()
var lastLocation = Location(LocationManager.FUSED_PROVIDER)
var curBearing = 0f
val parser = GPXParser()
val parsedGpx: Gpx? =
parser.parse(route.byteInputStream())
parsedGpx?.let {
val tracks = parsedGpx.tracks
tracks.forEach { tr ->
val segments: MutableList<TrackSegment?>? = tr.trackSegments
segments!!.forEach { seg ->
var lastTime = DateTime.now()
seg!!.trackPoints.forEach { p ->
val ext = p.extensions
var curSpeed = 0F
if (ext != null) {
curSpeed = ext.speed.toFloat()
}
val duration = p.time.millis - lastTime.millis
val fakeLocation = Location(LocationManager.FUSED_PROVIDER).apply {
latitude = p.latitude
longitude = p.longitude
speedAccuracyMetersPerSecond = 1.0f // ~1 m/s
speed = curSpeed
time = System.currentTimeMillis()
elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
}
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 1 second)
if (duration > 100) {
// delay(duration / 4)
}
Thread.sleep(2000)
lastTime = p.time
lastLocation = fakeLocation
}
}
}
}
}.run()
}
}
@@ -1,6 +1,5 @@
package com.kouros.navigation.car.screen
import android.location.Location
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
@@ -13,26 +12,32 @@ import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.data.Category
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.ViewModel
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
class CategoriesScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
private val location: Location,
private val viewModel: ViewModel
private val navigationViewModel: NavigationViewModel,
) : Screen(carContext) {
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 {
}
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage("No categories to show")
@@ -40,16 +45,16 @@ class CategoriesScreen(
itemListBuilder.addItem(
Row.Builder()
.setTitle(it.name)
.setImage(carIcon(carContext,it.id))
.setImage(carIcon(carContext, it.id, -1))
.setOnClickListener {
category = it.id
screenManager
.pushForResult(
CategoryScreen(
carContext,
surfaceRenderer,
location,
it.id,
viewModel
category,
navigationViewModel,
)
) { obj: Any? ->
if (obj != null) {
@@ -77,18 +82,22 @@ class CategoriesScreen(
}
}
fun carIcon(context: CarContext, id: String): CarIcon {
val resId = when (id) {
FUEL_STATION -> R.drawable.local_gas_station_48px
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(
IconCompat.createWithResource(
context,
resId as Int
createNumberIcon(
category,
index.toString()
)
)
.build()
).build()
}
}
@@ -1,11 +1,12 @@
package com.kouros.navigation.car.screen
import android.location.Location
import androidx.annotation.DrawableRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarText
import androidx.car.app.model.Header
@@ -15,14 +16,20 @@ 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 androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.NavigationMessage
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.NavigationRepository
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.ViewModel
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils.createPointCollection
import com.kouros.navigation.utils.location
import com.kouros.navigation.utils.round
@@ -31,77 +38,87 @@ import kotlin.math.min
class CategoryScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
location: Location,
private val category: String,
private val viewModel: ViewModel
) : Screen(carContext) {
private val navigationViewModel: NavigationViewModel,
var elements = listOf<Elements>()
) : Screen(carContext), CategoryObserverCallback {
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()
}
}
val maxListItems: Int = 30
var elements: List<Elements> = emptyList()
private val categoryObserver = CategoryObserver(this)
private var loading = true
init {
viewModel.elements.observe(this, observer)
viewModel.getAmenities(category, location)
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
navigationViewModel.elements.value = emptyList()
}
})
navigationViewModel.elements.observe(this, categoryObserver)
navigationViewModel.getAmenities(category, surfaceRenderer.lastLocation)
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
var index = 0
// Some hosts may allow more items in the list than others, so create more.
if (carContext.getCarAppApiLevel() > CarAppApiLevels.LEVEL_1) {
val listLimit = min(
50,
maxListItems,
carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(
ConstraintManager.CONTENT_LIMIT_TYPE_LIST
)
)
elements.forEach {
if (index++ < listLimit) {
if (it.tags.operator != null) {
if (index++ < listLimit) {
listBuilder.addItem(
createItem(it, category)
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(
ListTemplate.Builder()
.setHeader(header)
.setSingleList(listBuilder.build())
.build()
)
.setMapController(
MapController.Builder().setMapActionStrip(
getMapActionStrip()
).build()
)
val content = ListTemplate.Builder()
.setHeader(header)
if (loading) {
content.setLoading(true)
} else {
content.setSingleList(listBuilder.build())
}
.build()
builder.setContentTemplate(content.build())
return builder.build()
}
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()
@@ -111,24 +128,44 @@ class CategoryScreen(
}
val row = Row.Builder()
.setOnClickListener {
val location = location(it.lon!!, it.lat!!)
surfaceRenderer.setCategoryLocation(location, category)
val location = location(it.lon, it.lat)
surfaceRenderer.setCategoryLocation(location)
}
.setTitle(name)
.setImage(carIcon(carContext, category))
.setImage(carIcon(carContext, category, index))
if (it.distance < 1000) {
row.addText("${(it.distance).toInt()} m")
} else {
row.addText("${(it.distance / 1000).round(1)} km")
}
if (category == Constants.CHARGING_STATION) {
if (category == CHARGING_STATION) {
if (it.tags.socketType2 != null)
row.addText("${it.tags.socketType2} X Typ 2 ${it.tags.socketType2Output}")
} else {
row.addText(carText("${it.tags.openingHours}"))
}
row.addAction(
createAction(carContext, R.drawable.navigation_48px, FLAG_DEFAULT, {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
listOf(location(it.lon, it.lat)),
surfaceRenderer.carOrientation
)
setResult(
Place(
name = name,
category = CHARGING_STATION,
latitude = it.lat,
longitude = it.lon
)
)
finish()
}))
return row.build()
}
private fun carText(sText: String): CarText {
val secondText =
CarText.Builder(
@@ -160,10 +197,25 @@ class CategoryScreen(
@DrawableRes iconRes: Int,
scale: Int
): Action {
val navigationMessage = NavigationMessage(carContext)
return Action.Builder()
.setOnClickListener { surfaceRenderer.handleScale(scale) }
.setIcon(navigationMessage.createCarIcon(iconRes))
.build()
return createAction(carContext, iconRes, FLAG_IS_PERSISTENT, {
surfaceRenderer.handleScale(scale)
})
}
override fun onCategoryElementsReady(
elements: List<Elements>,
centerLat: Double,
centerLon: Double,
coordinates: List<List<Double>>
) {
val loc = location(centerLon, centerLat)
val route = createPointCollection(coordinates, category)
surfaceRenderer.setCategories(loc, route)
this.elements = elements
loading = false
}
override fun invalidateScreen() {
invalidate()
}
}
@@ -0,0 +1,22 @@
package com.kouros.navigation.car.screen
import androidx.car.app.navigation.model.Trip
import com.kouros.navigation.data.Place
/** A listener for navigation start and stop signals. */
interface NavigationListener {
/** Stops navigation. */
fun stopNavigation()
/** Starts navigation. */
fun startNavigation()
/** Updates trip information. */
fun updateTrip(trip: Trip)
fun navigateToPlace(place: Place)
fun recalcRoute(destination: Place)
}
@@ -1,166 +1,227 @@
package com.kouros.navigation.car.screen
import android.location.Location
import android.location.LocationManager
import android.os.CountDownTimer
import android.os.Handler
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.model.Header
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.Destination
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.PanModeListener
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.settings.SettingsScreen
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.utils.location
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class NavigationScreen(
/**
* Main screen for car navigation.
* Handles different navigation states and provides corresponding templates.
*/
open class NavigationScreen(
carContext: CarContext,
private var surfaceRenderer: SurfaceRenderer,
private var routeModel: RouteCarModel,
private var listener: Listener,
private val viewModel: ViewModel
) :
Screen(carContext) {
private var listener: NavigationListener,
private val navigationViewModel: NavigationViewModel
) : Screen(carContext) {
/** A listener for navigation start and stop signals. */
interface Listener {
/** Stops navigation. */
fun stopNavigation()
}
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
var recentPlace = Place()
var recentPlaces = mutableListOf<Place>()
var recentPlace: Place = Place()
var navigationType = NavigationType.VIEW
val observer = Observer<String> { route ->
if (route.isNotEmpty()) {
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route)
surfaceRenderer.setRouteData()
invalidate()
}
}
val repository = getSettingsRepository(carContext)
val recentObserver = Observer<Place> { lastPlace ->
if (!routeModel.isNavigating()) {
recentPlace = lastPlace
val settingsViewModel = getSettingsViewModel(carContext)
private var tripSuggestion = false
private var tripSuggestionCalled = false
private var arrivalTimer: CountDownTimer? = null
private var reRouteTimer: CountDownTimer? = null
private var isNavigating = false
private var isRerouting = false
private var hasArrived = false
private lateinit var destinations: MutableList<Destination>
private lateinit var stepRemainingDistance: Distance
private lateinit var destinationTravelEstimate: TravelEstimate
private lateinit var stepTravelEstimate: TravelEstimate
private var shouldShowNextStep = false
private var shouldShowLanes = false
private lateinit var steps: MutableList<Step>
private var junctionImage: CarIcon? = null
private var backGroundColor = CarColor.BLUE
private var showAlternativeRoute = false
val observerRecentPlaces = Observer<List<Place>> { newPlaces ->
Log.d(TAG, "NavigationScreen 4")
recentPlaces.addAll(newPlaces)
if (newPlaces.isNotEmpty() && !tripSuggestionCalled) {
tripSuggestionCalled = true
navigationType = NavigationType.RECENT
invalidate()
}
}
val placeObserver = Observer<SearchResult> { searchResult ->
val place = Place(
name = searchResult.displayName,
street = searchResult.address.road,
city = searchResult.address.city,
latitude = searchResult.lat.toDouble(),
longitude = searchResult.lon.toDouble(),
category = Constants.CONTACTS,
postalCode = searchResult.address.postcode
)
navigateToPlace(place)
}
var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
val speedObserver = Observer<List<Elements>> { cameras ->
speedCameras = cameras
}
init {
viewModel.route.observe(this, observer)
viewModel.recentPlace.observe(this, recentObserver)
viewModel.loadRecentPlace(location = surfaceRenderer.lastLocation)
viewModel.placeLocation.observe(this, placeObserver)
viewModel.speedCameras.observe(this, speedObserver)
lifecycleScope.launch {
settingsViewModel.tripSuggestion.first()
}
repository.tripSuggestionFlow.asLiveData().observe(this, Observer {
Log.d(TAG, "NavigationScreen 3")
navigationViewModel.recentPlaces.observe(this, observerRecentPlaces)
tripSuggestion = it
})
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
showAlternativeRoute = it
})
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
arrivalTimer?.cancel()
reRouteTimer?.cancel()
}
})
}
/**
* Returns the appropriate template based on the current navigation state.
*/
override fun onGetTemplate(): Template {
val actionStripBuilder = createActionStripBuilder()
Log.d(TAG, "NavigationScreen 2")
val actionStripBuilder = createActionStripBuilder({
createAction(
carContext,
R.drawable.search_48px,
onClickAction = { startSearchScreen() }
)
}, { settingsAction() })
return when (navigationType) {
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
NavigationType.RECENT -> navigationRecentPlaceTemplate()
NavigationType.REROUTE -> navigationRerouteTemplate(actionStripBuilder)
NavigationType.ARRIVAL -> navigationEndTemplate(actionStripBuilder)
else -> navigationViewTemplate(actionStripBuilder)
NavigationType.NAVIGATION -> navigation(actionStripBuilder)
NavigationType.RECENT -> navigationRecentPlaces()
NavigationType.REROUTE -> navigationReroute(actionStripBuilder)
NavigationType.ARRIVAL -> navigationEnd(actionStripBuilder)
else -> navigationView(actionStripBuilder)
}
}
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
/**
* Creates and returns a NavigationTemplate for the active navigation state.
*/
private fun navigation(actionStripBuilder: ActionStrip.Builder): Template {
actionStripBuilder.addAction(
stopAction()
createAction(
carContext,
R.drawable.ic_close_white_24dp,
0,
{ stopNavigation() })
)
return NavigationTemplate.Builder()
.setNavigationInfo(
getRoutingInfo()
)
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext))
.setDestinationTravelEstimate(destinationTravelEstimate)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setBackgroundColor(CarColor.GREEN)
.setMapActionStrip(
mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
Action.Builder()
.setIcon(createCarIcon(carContext, R.drawable.ic_recenter_24))
.setFlags(0)
.setOnClickListener {
surfaceRenderer.setStandardView()
invalidate()
}
.build()
})
)
.setBackgroundColor(backGroundColor)
.build()
}
private fun navigationViewTemplate(actionStripBuilder: ActionStrip.Builder): Template {
/**
* Creates and returns a template for the default view state.
*/
private fun navigationView(actionStripBuilder: ActionStrip.Builder): Template {
val mapActionStrip = mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_recenter_24,
0,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
return NavigationTemplate.Builder()
.setBackgroundColor(CarColor.SECONDARY)
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setMapActionStrip(mapActionStrip)
.setPanModeListener { isInPanMode: Boolean ->
Log.d(TAG, "PanMode $isInPanMode")
}
.build()
}
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
if (routeModel.routeState.arrived) {
val timer = object : CountDownTimer(8000, 1000) {
/**
* Creates and returns a template for the arrival.
*/
private fun navigationEnd(actionStripBuilder: ActionStrip.Builder): Template {
arrivalTimer?.cancel()
arrivalTimer = object : CountDownTimer(8000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
routeModel.routeState = routeModel.routeState.copy(arrived = false)
// routeModel.navState = routeModel.navState.copy(arrived = false)
navigationType = NavigationType.VIEW
invalidate()
}
}
timer.start()
return navigationArrivedTemplate(actionStripBuilder)
} else {
return NavigationTemplate.Builder()
.setBackgroundColor(CarColor.SECONDARY)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.build()
}
arrivalTimer?.start()
return navigationArrived(actionStripBuilder)
}
fun navigationArrivedTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
/**
* Creates and returns a NavigationTemplate specifically for when the destination is reached.
*/
fun navigationArrived(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
var street = ""
if (routeModel.routeState.destination.street != null) {
street = routeModel.routeState.destination.street!!
if (destinations.first().address != null) {
street = destinations.first().address.toString()
}
return NavigationTemplate.Builder()
.setNavigationInfo(
@@ -179,323 +240,301 @@ class NavigationScreen(
)
.build()
)
.setBackgroundColor(CarColor.GREEN)
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setMapActionStrip(
mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_recenter_24,
0,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
.build()
}
fun navigationRecentPlaceTemplate(): Template {
val messageTemplate = MessageTemplate.Builder(
recentPlace.name + "\n"
+ recentPlace.city
/**
* Creates and returns a template showing recent places or destinations.
*/
fun navigationRecentPlaces(): Template {
if (!tripSuggestion || recentPlaces.isEmpty()) {
navigationType = NavigationType.VIEW
return navigationView(
createActionStripBuilder(
{
createAction(
carContext,
R.drawable.search_48px,
0,
{ startSearchScreen() })
},
{ settingsAction() })
)
}
val listBuilder = ItemList.Builder()
recentPlaces.filter { it.category == Constants.RECENT && it.distance > 300F }.forEach {
val row = Row.Builder()
.setTitle(it.name)
.addAction(
createNavigateAction(it)
)
.setOnClickListener {
listener.navigateToPlace(it)
}
listBuilder.addItem(
row.build()
)
}
val contentTemplate =
ListTemplate.Builder()
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.drive_now))
.addEndHeaderAction(closeAction())
.build()
)
.addAction(navigateAction())
.addAction(closeAction())
.setSingleList(listBuilder.build())
.build()
val builder = MapWithContentTemplate.Builder()
.setContentTemplate(messageTemplate)
.setContentTemplate(contentTemplate)
.setActionStrip(
mapActionStripBuilder()
.addAction(settingsAction())
.addAction(searchAction())
.build()
mapActionStrip(
carContext,
ViewStyle.VIEW,
{ settingsAction() },
{
createAction(
carContext,
R.drawable.search_48px,
FLAG_IS_PERSISTENT,
{ startSearchScreen() })
},
{
createAction(
carContext = carContext, R.drawable.ic_recenter_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
return builder.build()
}
fun navigationRerouteTemplate(actionStripBuilder: ActionStrip.Builder): Template {
/**
* Creates and returns a template for when the route is being recalculated.
*/
fun navigationReroute(actionStripBuilder: ActionStrip.Builder): Template {
return NavigationTemplate.Builder()
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
.setActionStrip(actionStripBuilder.build())
.setBackgroundColor(CarColor.GREEN)
.setBackgroundColor(backGroundColor)
.build()
}
/**
* Builds and returns RoutingInfo based on the current step and distance.
*/
fun getRoutingInfo(): RoutingInfo {
var currentDistance = routeModel.leftStepDistance()
val displayUnit = if (currentDistance > 1000.0) {
currentDistance /= 1000.0
Distance.UNIT_KILOMETERS
} else {
Distance.UNIT_METERS
}
val nextStep = routeModel.nextStep(carContext = carContext)
if (nextStep != null) {
return RoutingInfo.Builder()
.setCurrentStep(
routeModel.currentStep(carContext = carContext),
Distance.create(currentDistance, displayUnit)
val routingInfo = RoutingInfo.Builder()
if (steps.isNotEmpty()) {
routingInfo.setCurrentStep(
steps.first(),
stepRemainingDistance
)
.setNextStep(nextStep)
.build()
} else {
return RoutingInfo.Builder()
.setCurrentStep(
routeModel.currentStep(carContext = carContext),
Distance.create(currentDistance, displayUnit)
)
.build()
}
if (shouldShowNextStep && steps.size > 1) {
routingInfo.setNextStep(steps[1])
}
return routingInfo.build()
}
private fun createActionStripBuilder(): ActionStrip.Builder {
val actionStripBuilder: ActionStrip.Builder = ActionStrip.Builder()
actionStripBuilder.addAction(
searchAction()
)
actionStripBuilder.addAction(
settingsAction()
)
return actionStripBuilder
}
private fun mapActionStripBuilder(): ActionStrip.Builder {
val actionStripBuilder = ActionStrip.Builder()
.addAction(zoomPlus())
.addAction(zoomMinus())
if (surfaceRenderer.viewStyle == ViewStyle.PAN_VIEW) {
actionStripBuilder
.addAction(
panAction()
)
}
return actionStripBuilder
}
private fun stopAction(): Action {
return Action.Builder()
.setTitle(carContext.getString(R.string.stop_action_title))
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
/**
* Creates an action to start navigation to a specific place.
*/
private fun createNavigateAction(place: Place): Action {
recentPlace = place
return createAction(
carContext, R.drawable.chevron_right_24px,
onClickAction = {
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
R.drawable.ic_close_white_24dp
RoutePreviewType.SINGLE_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
showAlternativeRoute
)
)
.build()
)
.setOnClickListener {
stopNavigation()
) { obj: Any? ->
if (obj != null) {
listener.navigateToPlace(place)
}
.build()
}
private fun navigateAction(): Action {
navigationType = NavigationType.NAVIGATION
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.navigation_48px
)
)
.build()
)
.setOnClickListener {
val navigateTo = location(recentPlace.longitude, recentPlace.latitude)
viewModel.loadRoute(carContext, surfaceRenderer.lastLocation, navigateTo)
routeModel.routeState = routeModel.routeState.copy(destination = recentPlace)
}
.build()
}
}
)
}
/**
* Creates an action to close the current view or template.
*/
private fun closeAction(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_close_white_24dp
)
)
.build()
)
.setOnClickListener {
return createAction(
carContext, R.drawable.ic_close_white_24dp,
onClickAction = {
navigationType = NavigationType.VIEW
invalidate()
}
.setFlags(FLAG_DEFAULT)
.build()
}
private fun searchAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.search_48px))
.setOnClickListener {
startSearchScreen()
}
.build()
)
}
/**
* Creates an action to start the settings screen.
*/
private fun settingsAction(): Action {
return Action.Builder()
.setIcon(routeModel.createCarIcon(carContext, R.drawable.settings_applications_48px))
.setOnClickListener {
screenManager.push(SettingsScreen(carContext))
return createAction(
carContext, R.drawable.settings_48px,
0,
onClickAction = {
screenManager.push(SettingsScreen(carContext, navigationViewModel))
}
.build()
)
}
/**
* Creates an action to zoom in on the map.
*/
private fun zoomPlus(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_zoom_in_24
)
)
.build()
).setOnClickListener {
return createAction(
carContext, R.drawable.ic_zoom_in_24,
0,
onClickAction = {
surfaceRenderer.handleScale(1)
invalidate()
}
.build()
)
}
/**
* Creates an action to zoom out on the map.
*/
private fun zoomMinus(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_zoom_out_24
)
)
.build()
).setOnClickListener {
return createAction(
carContext, R.drawable.ic_zoom_out_24,
0,
onClickAction = {
surfaceRenderer.handleScale(-1)
invalidate()
}
.build()
)
}
private fun panAction(): Action {
return Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_pan_24
)
)
.build()
).setOnClickListener {
surfaceRenderer.viewStyle = ViewStyle.VIEW
}
.build()
}
/**
* Pushes the search screen and handles the search result.
*/
private fun startSearchScreen() {
screenManager
.pushForResult(
SearchScreen(carContext, surfaceRenderer, surfaceRenderer.lastLocation, viewModel)
SearchScreen(
carContext,
surfaceRenderer,
navigationViewModel,
recentPlaces
)
) { obj: Any? ->
if (obj != null) {
val place = obj as Place
if (place.longitude == 0.0) {
viewModel.findAddress(
navigationViewModel.findAddress(
"${obj.city} ${obj.street}},",
currentNavigationLocation
surfaceRenderer.lastLocation
)
// result see observer
} else {
navigateToPlace(place)
listener.navigateToPlace(place)
}
}
}
}
fun navigateToPlace(place: Place) {
navigationType = NavigationType.VIEW
val location = location(place.longitude, place.latitude)
viewModel.saveRecent(place)
currentNavigationLocation = location
viewModel.loadRoute(carContext, surfaceRenderer.lastLocation, location)
routeModel.routeState = routeModel.routeState.copy(destination = place)
invalidate()
}
/**
* Stops navigation, resets state, and notifies listeners.
*/
fun stopNavigation() {
navigationType = NavigationType.VIEW
listener.stopNavigation()
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
invalidate()
}
/**
* Initiates recalculation for a new route to the destination.
*/
fun calculateNewRoute(destination: Place) {
stopNavigation()
navigationType = NavigationType.REROUTE
invalidate()
val mainThreadHandler = Handler(carContext.mainLooper)
mainThreadHandler.post {
object : CountDownTimer(3000, 1000) {
reRouteTimer?.cancel()
reRouteTimer = object : CountDownTimer(2000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
navigationType = NavigationType.NAVIGATION
reRoute(destination)
listener.recalcRoute(destination)
}
}.start()
}
reRouteTimer?.start()
}
}
fun reRoute(destination: Place) {
val dest = location(destination.longitude, destination.latitude)
viewModel.loadRoute(carContext, surfaceRenderer.lastLocation, dest)
}
fun updateTrip(location: Location) {
updateSpeedCamera(location)
with(routeModel) {
updateLocation(location, viewModel)
if (routeState.maneuverType == Maneuver.TYPE_DESTINATION
&& leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
/**
* Updates navigation state with the current location, checks for arrival, and traffic updates.
*/
fun updateTrip(
isNavigating: Boolean,
isRerouting: Boolean,
hasArrived: Boolean,
destinations: MutableList<Destination>,
steps: MutableList<Step>,
destinationTravelEstimate: TravelEstimate,
stepTravelEstimate: TravelEstimate,
stepRemainingDistance: Distance,
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?,
backGroundColor: CarColor
) {
stopNavigation()
routeState = routeState.copy(arrived = true)
surfaceRenderer.routeData.value = ""
navigationType = NavigationType.ARRIVAL
this.isNavigating = isNavigating
this.isRerouting = isRerouting
this.hasArrived = hasArrived
this.destinations = destinations
this.steps = steps
this.stepRemainingDistance = stepRemainingDistance
this.destinationTravelEstimate = destinationTravelEstimate
this.stepTravelEstimate = stepTravelEstimate
this.shouldShowNextStep = shouldShowNextStep
this.shouldShowLanes = shouldShowLanes
this.junctionImage = junctionImage
this.backGroundColor = backGroundColor
navigationType = NavigationType.NAVIGATION
invalidate()
}
}
invalidate()
}
private fun updateSpeedCamera(location: Location) {
if (lastCameraSearch++ % 100 == 0) {
viewModel.getSpeedCameras(location)
}
if (speedCameras.isNotEmpty()) {
updateDistance(location)
}
}
private fun updateDistance(
location: Location,
) {
val updatedCameras = mutableListOf<Elements>()
speedCameras.forEach {
val plLocation =
location(longitude = it.lon!!, latitude = it.lat!!)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.first()
if (camera.distance < 80) {
routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
}
}
}
/**
* Defines the possible states for the navigation UI.
*/
enum class NavigationType {
VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL
}
@@ -1,73 +0,0 @@
package com.kouros.navigation.car.screen
import androidx.car.app.CarContext
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.Template
import androidx.car.app.model.Toggle
import com.kouros.data.R
import com.kouros.navigation.data.Constants.AVOID_MOTORWAY
import com.kouros.navigation.data.Constants.AVOID_TOLLWAY
import com.kouros.navigation.utils.NavigationUtils.getBooleanKeyValue
import com.kouros.navigation.utils.NavigationUtils.setBooleanKeyValue
class NavigationSettings(private val carContext: CarContext) : Screen(carContext) {
private var motorWayToggleState = false
private var tollWayToggleState = false
init {
motorWayToggleState = getBooleanKeyValue(carContext, AVOID_MOTORWAY)
tollWayToggleState = getBooleanKeyValue(carContext, AVOID_MOTORWAY)
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
val highwayToggle: Toggle =
Toggle.Builder { checked: Boolean ->
if (checked) {
setBooleanKeyValue(carContext, true, AVOID_MOTORWAY)
} else {
setBooleanKeyValue(carContext, false, AVOID_MOTORWAY)
}
motorWayToggleState = !motorWayToggleState
}.setChecked(motorWayToggleState).build()
listBuilder.addItem(buildRowForTemplate(R.string.avoid_highways_row_title, highwayToggle))
// Tollway
val tollwayToggle: Toggle =
Toggle.Builder { checked: Boolean ->
if (checked) {
setBooleanKeyValue(carContext, true, AVOID_TOLLWAY)
} else {
setBooleanKeyValue(carContext, false, AVOID_TOLLWAY)
}
tollWayToggleState = !tollWayToggleState
}.setChecked(tollWayToggleState).build()
listBuilder.addItem(buildRowForTemplate(R.string.avoid_tolls_row_title, tollwayToggle))
return ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.display_settings))
.setStartHeaderAction(Action.BACK)
.build()
)
.build()
}
private fun buildRowForTemplate(title: Int, toggle: Toggle): Row {
return Row.Builder()
.setTitle(carContext.getString(title))
.setToggle(toggle)
.build()
}
}

Some files were not shown because too many files have changed in this diff Show More