Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b99ebfd36f | ||
|
|
69b27d3b6c | ||
|
|
8b886c36b1 | ||
|
|
2ce079a7c1 | ||
|
|
8af2d3ad0b | ||
|
|
1d67b3cc06 | ||
|
|
a4227c80d3 | ||
|
|
757c4c8d8d | ||
|
|
24173412e8 | ||
|
|
52f8dec2e6 | ||
|
|
6838ad09c4 | ||
|
|
60b842d883 | ||
|
|
9def7a5c64 | ||
|
|
bd8a497fbe | ||
|
|
8ca450cd10 | ||
|
|
94d6d6d311 | ||
|
|
d81d33df43 | ||
|
|
5317a14fb3 | ||
|
|
90010d91b7 | ||
|
|
2348d3b633 | ||
|
|
263b5b576d | ||
|
|
aaa57c14b8 | ||
|
|
a3370e42a8 | ||
|
|
5098dad9d6 | ||
|
|
bc8a53a5d8 | ||
|
|
d1968cfa68 | ||
|
|
ada878b23c | ||
|
|
5198725879 | ||
|
|
619ceb9f83 | ||
|
|
61ce09f393 | ||
|
|
8c103a1f96 | ||
|
|
e582c1e0dc | ||
|
|
11e9dbb21e | ||
|
|
e1af3e19fa | ||
|
|
378ee8c227 | ||
|
|
a468529ca4 | ||
|
|
eb6d3e4ef7 | ||
|
|
5a6165dff8 | ||
|
|
e4b539c4e6 | ||
|
|
71d3d17847 | ||
|
|
723707dac6 | ||
|
|
ebd97cf1c9 | ||
|
|
65ff41995d | ||
|
|
5141041b5c | ||
|
|
e9474695bf | ||
|
|
0d51c6121d | ||
|
|
eac5b56bcb | ||
|
|
7db7cba4fb | ||
|
|
e22865bd73 | ||
|
|
e274011080 | ||
|
|
7efa2685be | ||
|
|
fdf2ee9f48 | ||
|
|
1eab4f1aa3 | ||
|
|
9f356bd728 | ||
|
|
45c8bb5ccc | ||
|
|
82027dce76 | ||
|
|
1b8abbd4eb | ||
|
|
d0a07e1315 | ||
|
|
ddae6f2189 | ||
|
|
01a4eb7fca | ||
|
|
8673380b9e |
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
# README.md
|
||||
|
||||
## Introduction
|
||||
|
||||
## Simulation
|
||||
|
||||
adb shell dumpsys activity service com.kouros.navigation.car.NavigationCarAppService AUTO_DRIVE
|
||||
|
||||
## Signing
|
||||
./gradlew bundleFull
|
||||
@@ -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 = 13
|
||||
versionName = "0.1.3.13"
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
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) {
|
||||
@@ -87,4 +98,5 @@ 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()
|
||||
|
||||
val locationProvider = rememberDefaultLocationProvider(
|
||||
updateInterval = 0.5.seconds,
|
||||
desiredAccuracy = DesiredAccuracy.Highest
|
||||
)
|
||||
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
|
||||
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
|
||||
)
|
||||
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)
|
||||
val step: StepData? by stepData.observeAsState()
|
||||
val nextStep: StepData? by nextStepData.observeAsState()
|
||||
|
||||
fun openSheet() {
|
||||
scope.launch { scaffoldState.bottomSheetState.expand() }
|
||||
}
|
||||
|
||||
fun closeSheet() {
|
||||
scope.launch {
|
||||
scaffoldState.bottomSheetState.partialExpand()
|
||||
sheetPeekHeightState.value = 128.dp
|
||||
NavigationTheme(useDarkTheme = darkMode == 1) {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||
SheetLayout(
|
||||
map = { _ ->
|
||||
Map(
|
||||
userLocationState, step, nextStep, baseStyle, navController
|
||||
)
|
||||
},
|
||||
menu = { SheetContent(navController, step, nextStep) },
|
||||
)
|
||||
}
|
||||
}
|
||||
NavigationTheme {
|
||||
BottomSheetScaffold(
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Map(
|
||||
userLocationState: UserLocationState,
|
||||
step: StepData?,
|
||||
nextStep: StepData?,
|
||||
baseStyle: BaseStyle.Json,
|
||||
navController: NavHostController
|
||||
) {
|
||||
MapView(
|
||||
applicationContext,
|
||||
userLocationState,
|
||||
step,
|
||||
nextStep,
|
||||
cameraPosition,
|
||||
routeData,
|
||||
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")
|
||||
},
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = sheetPeekHeightState.value,
|
||||
sheetContent = {
|
||||
SheetContent(latitude, step, nextStep) { closeSheet() }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MapView(
|
||||
applicationContext,
|
||||
userLocationState,
|
||||
step,
|
||||
cameraPosition,
|
||||
routeData,
|
||||
tilt
|
||||
)
|
||||
}
|
||||
) {
|
||||
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 {
|
||||
NavigationSheet(
|
||||
routeModel, step!!, nextStep!!,
|
||||
{ stopNavigation { closeSheet() } },
|
||||
{ simulateNavigation() }
|
||||
)
|
||||
if (step != null) {
|
||||
NavigationSheet(
|
||||
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)
|
||||
with(routeModel) {
|
||||
if (isNavigating()) {
|
||||
updateLocation(currentLocation, navigationViewModel)
|
||||
stepData.value = currentStep()
|
||||
if (route.currentManeuverIndex + 1 <= route.maneuvers.size) {
|
||||
nextStepData.value = nextStep()
|
||||
}
|
||||
if (routeState.maneuverType == 39
|
||||
&& leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
|
||||
) {
|
||||
stopNavigation()
|
||||
routeState = routeState.copy(arrived = true)
|
||||
routeData.value = ""
|
||||
}
|
||||
}
|
||||
if (location.bearing != null && location.bearingAccuracy!! <= 20.0) {
|
||||
currentLocation.bearing = location.bearing!!.toFloat()
|
||||
}
|
||||
val bearing = bearing(lastLocation, currentLocation, cameraPosition.value!!.bearing)
|
||||
val zoom = calculateZoom(location.speed)
|
||||
cameraPosition.postValue(
|
||||
cameraPosition.value!!.copy(
|
||||
zoom = zoom,
|
||||
target = location.position,
|
||||
bearing = bearing
|
||||
),
|
||||
)
|
||||
lastLocation = currentLocation
|
||||
if (!loadRecentPlaces) {
|
||||
navigationViewModel.loadRecentPlaces(applicationContext, lastLocation)
|
||||
loadRecentPlaces = true
|
||||
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 (guidanceAudio == 1) {
|
||||
textToSpeech()
|
||||
}
|
||||
if (navState.nextStep) {
|
||||
nextStepData.value = nextStep()
|
||||
}
|
||||
if (navState.maneuverType in 39..42 && routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE) {
|
||||
// stopNavigation()
|
||||
navState = navState.copy(arrived = true)
|
||||
routeData.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
//val zoom = calculateZoom(location.speed)
|
||||
val zoom = 16.0
|
||||
cameraPosition.postValue(
|
||||
cameraPosition.value!!.copy(
|
||||
zoom = zoom, target = location!!.position, bearing = bearing
|
||||
),
|
||||
)
|
||||
lastLocation = currentLocation
|
||||
if (!loadRecentPlaces) {
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,15 +59,26 @@ 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)
|
||||
LocationTrackingEffect(
|
||||
MapLibre(
|
||||
cameraState,
|
||||
baseStyle,
|
||||
route,
|
||||
emptyMap(),
|
||||
ViewStyle.VIEW,
|
||||
speedCameras = "",
|
||||
showBuildings
|
||||
)
|
||||
LocationTrackingEffect(
|
||||
locationState = userLocationState,
|
||||
) {
|
||||
cameraState.animateTo(
|
||||
@@ -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.ic_search_black36dp),
|
||||
"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()) {
|
||||
@@ -55,7 +68,7 @@ fun NavigationSheet(
|
||||
simulateNavigation()
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.assistant_navigation_48px),
|
||||
painter = painterResource(id = R.drawable.navigation_48px),
|
||||
"Simulate",
|
||||
modifier = Modifier.size(24.dp, 24.dp),
|
||||
)
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2021 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.
|
||||
-->
|
||||
<automotiveApp>
|
||||
<uses name="template" />
|
||||
</automotiveApp>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +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" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<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 +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" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Navigation</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2021 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.
|
||||
-->
|
||||
<automotiveApp>
|
||||
<uses name="template" />
|
||||
</automotiveApp>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
Copyright (C) 2021 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.
|
||||
-->
|
||||
<paths>
|
||||
<files-path name="res" path="/res"/>
|
||||
</paths>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 22 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,46 +1,57 @@
|
||||
/*
|
||||
* 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 {
|
||||
|
||||
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -111,7 +179,7 @@ class SurfaceRenderer(
|
||||
this.setViewTreeLifecycleOwner(lifecycleOwner)
|
||||
this.setViewTreeSavedStateRegistryOwner(lifecycleOwner)
|
||||
setContent {
|
||||
MapView()
|
||||
MapView()
|
||||
}
|
||||
}
|
||||
presentation = Presentation(carContext, virtualDisplay.display)
|
||||
@@ -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)
|
||||
val currentSpeed: Float? by speed.observeAsState()
|
||||
if (viewStyle == ViewStyle.VIEW) {
|
||||
DrawNavigationImages(paddingValues, currentSpeed, routeModel.routeState.maxSpeed, width, height)
|
||||
duration(
|
||||
viewStyle == ViewStyle.PREVIEW,
|
||||
position!!.bearing,
|
||||
lastBearing,
|
||||
lastLocationUpdate
|
||||
)
|
||||
val currentSpeed: Float? by speed.observeAsState()
|
||||
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)
|
||||
}
|
||||
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,80 +397,122 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCameraPosition(bearing: Double, zoom: Double, target: Position) {
|
||||
cameraPosition.postValue(
|
||||
cameraPosition.value!!.copy(
|
||||
bearing = bearing,
|
||||
zoom = zoom,
|
||||
tilt = tilt,
|
||||
padding = getPaddingValues(height, viewStyle),
|
||||
target = target
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun setRouteData() {
|
||||
routeData.value = routeModel.route.routeGeoJson
|
||||
/**
|
||||
* Sets route data for active navigation and switches to VIEW mode.
|
||||
*/
|
||||
fun setRouteData(routeGeoJson: String) {
|
||||
routeData.value = routeGeoJson
|
||||
viewStyle = ViewStyle.VIEW
|
||||
}
|
||||
|
||||
fun setPreviewRouteData(routeModel: RouteModel) {
|
||||
/**
|
||||
* 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,
|
||||
zoom = zoom,
|
||||
tilt = tilt,
|
||||
padding = getPaddingValues(height, viewStyle),
|
||||
target = target
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
viewStyle = ViewStyle.AMENITY_VIEW
|
||||
routeData.value = route
|
||||
updateCameraPosition(
|
||||
0.0,
|
||||
12.0,
|
||||
target = Position(location.longitude, location.latitude)
|
||||
)
|
||||
synchronized(this) {
|
||||
viewStyle = ViewStyle.AMENITY_VIEW
|
||||
routeData.value = route
|
||||
tilt = 0.0
|
||||
updateCameraPosition(
|
||||
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,9 +1,8 @@
|
||||
package com.kouros.navigation.car.map
|
||||
|
||||
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
|
||||
@@ -11,10 +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
|
||||
@@ -23,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
|
||||
@@ -50,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
|
||||
@@ -59,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
|
||||
@@ -74,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(
|
||||
@@ -95,75 +102,186 @@ 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",
|
||||
source = routes,
|
||||
color = const(Color.White),
|
||||
width =
|
||||
interpolate(
|
||||
type = exponential(1.2f),
|
||||
input = zoom(),
|
||||
5 to const(0.4.dp),
|
||||
6 to const(0.8.dp),
|
||||
7 to const(2.0.dp),
|
||||
20 to const(24.dp),
|
||||
),
|
||||
)
|
||||
LineLayer(
|
||||
id = "routes",
|
||||
source = routes,
|
||||
color = const(RouteColor),
|
||||
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),
|
||||
),
|
||||
)
|
||||
LineLayer(
|
||||
id = "routes-casing",
|
||||
source = routes,
|
||||
color = const(Color.White),
|
||||
width =
|
||||
interpolate(
|
||||
type = exponential(1.2f),
|
||||
input = zoom(),
|
||||
5 to const(0.4.dp),
|
||||
6 to const(0.8.dp),
|
||||
7 to const(2.0.dp),
|
||||
20 to const(24.dp),
|
||||
),
|
||||
)
|
||||
LineLayer(
|
||||
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.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 != null && routeData.isNotEmpty()) {
|
||||
val color = if (routeData.contains(Constants.PHARMACY)) {
|
||||
const(Color.Red)
|
||||
} else {
|
||||
const(Color.Green)
|
||||
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 = image(painterResource(R.drawable.ev_station_48px), drawAsSdf = true),
|
||||
iconImage = img,
|
||||
iconColor = color,
|
||||
iconSize = const(3.0f),
|
||||
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 SpeedCameraLayer(speedCameras: String?) {
|
||||
if (!speedCameras.isNullOrEmpty()) {
|
||||
val color = const(Color.Red)
|
||||
val cameraSource = rememberGeoJsonSource(GeoJsonData.JsonString(speedCameras))
|
||||
SymbolLayer(
|
||||
id = "speed-camera-layer",
|
||||
source = cameraSource,
|
||||
iconImage = image(painterResource(R.drawable.speed_camera_24px), drawAsSdf = true),
|
||||
iconColor = color,
|
||||
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 BuildingLayer(tiles: Source) {
|
||||
Anchor.Replace("building-3d") {
|
||||
@@ -177,33 +295,97 @@ fun BuildingLayer(tiles: Source) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawNavigationImages(padding: PaddingValues, speed: Float?, maxSpeed: Int, width: Int, height: Int) {
|
||||
NavigationImage(padding, width, height)
|
||||
CurrentSpeed(width, height, speed)
|
||||
if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) {
|
||||
fun DrawNavigationImages(
|
||||
padding: PaddingValues,
|
||||
speed: Float?,
|
||||
maxSpeed: Int,
|
||||
width: Int,
|
||||
height: Int,
|
||||
streetName: String?,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
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.2f))
|
||||
drawCircle(navigationColor.copy(alpha = 0.3f))
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.navigation),
|
||||
painter = painterResource(id = R.drawable.navigation_48px),
|
||||
"Navigation",
|
||||
tint = color.copy(alpha = 1f),
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,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(
|
||||
@@ -224,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,
|
||||
@@ -235,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()) {
|
||||
@@ -261,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,
|
||||
@@ -278,7 +465,7 @@ private fun MaxSpeed(
|
||||
height: Int,
|
||||
maxSpeed: Int,
|
||||
) {
|
||||
val radius = 20
|
||||
val radius = 24
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
@@ -328,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)
|
||||
}
|
||||
if (darkMode == 1) {
|
||||
baseStyle.value = BaseStyle.Uri(Constants.STYLE_DARK)
|
||||
}
|
||||
if (darkMode == 2) {
|
||||
baseStyle.value =
|
||||
(if (isSystemInDarkTheme()) BaseStyle.Uri(Constants.STYLE_DARK) else 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)
|
||||
}
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -367,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(
|
||||
@@ -376,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()
|
||||
)
|
||||
.setRoad(routeState.destination.street!!)
|
||||
.build()
|
||||
return step
|
||||
|
||||
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()
|
||||
)
|
||||
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,17 +45,17 @@ 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) {
|
||||
setResult(obj)
|
||||
@@ -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(
|
||||
createNumberIcon(
|
||||
category,
|
||||
index.toString()
|
||||
)
|
||||
).build()
|
||||
}
|
||||
return CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
context,
|
||||
resId as Int
|
||||
)
|
||||
)
|
||||
.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
|
||||
val listLimit = min(
|
||||
50,
|
||||
carContext.getCarService(ConstraintManager::class.java)
|
||||
.getContentLimit(
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_LIST
|
||||
)
|
||||
)
|
||||
elements.forEach {
|
||||
if (index++ < listLimit) {
|
||||
if (it.tags.operator != null) {
|
||||
listBuilder.addItem(
|
||||
createItem(it, category)
|
||||
|
||||
// Some hosts may allow more items in the list than others, so create more.
|
||||
if (carContext.getCarAppApiLevel() > CarAppApiLevels.LEVEL_1) {
|
||||
val listLimit = min(
|
||||
maxListItems,
|
||||
carContext.getCarService(ConstraintManager::class.java)
|
||||
.getContentLimit(
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_LIST
|
||||
)
|
||||
)
|
||||
elements.forEach {
|
||||
if (it.tags.operator != null) {
|
||||
if (index++ < listLimit) {
|
||||
listBuilder.addItem(
|
||||
createItem(it, category, index)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val header = Header.Builder()
|
||||
.setStartHeaderAction(Action.BACK)
|
||||
.setTitle(carContext.getString(R.string.charging_station))
|
||||
.setTitle(getTitle(carContext, category))
|
||||
.build()
|
||||
val builder = MapWithContentTemplate.Builder()
|
||||
.setContentTemplate(
|
||||
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) {
|
||||
row.addText("${it.tags.socketType2} X Typ 2 ${it.tags.socketType2Output}")
|
||||
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())
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
|
||||
if (routeModel.routeState.arrived) {
|
||||
val timer = object : CountDownTimer(8000, 1000) {
|
||||
override fun onTick(millisUntilFinished: Long) {}
|
||||
override fun onFinish() {
|
||||
routeModel.routeState = routeModel.routeState.copy(arrived = false)
|
||||
navigationType = NavigationType.VIEW
|
||||
invalidate()
|
||||
}
|
||||
.setMapActionStrip(mapActionStrip)
|
||||
.setPanModeListener { isInPanMode: Boolean ->
|
||||
Log.d(TAG, "PanMode $isInPanMode")
|
||||
}
|
||||
timer.start()
|
||||
return navigationArrivedTemplate(actionStripBuilder)
|
||||
} else {
|
||||
return NavigationTemplate.Builder()
|
||||
.setBackgroundColor(CarColor.SECONDARY)
|
||||
.setActionStrip(actionStripBuilder.build())
|
||||
.setMapActionStrip(mapActionStripBuilder().build())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.navState = routeModel.navState.copy(arrived = false)
|
||||
navigationType = NavigationType.VIEW
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.drive_now))
|
||||
.build()
|
||||
/**
|
||||
* 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() })
|
||||
)
|
||||
.addAction(navigateAction())
|
||||
.addAction(closeAction())
|
||||
.build()
|
||||
}
|
||||
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()
|
||||
)
|
||||
.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)
|
||||
)
|
||||
.setNextStep(nextStep)
|
||||
.build()
|
||||
} else {
|
||||
return RoutingInfo.Builder()
|
||||
.setCurrentStep(
|
||||
routeModel.currentStep(carContext = carContext),
|
||||
Distance.create(currentDistance, displayUnit)
|
||||
)
|
||||
.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(
|
||||
carContext,
|
||||
R.drawable.ic_close_white_24dp
|
||||
)
|
||||
)
|
||||
.build()
|
||||
val routingInfo = RoutingInfo.Builder()
|
||||
if (steps.isNotEmpty()) {
|
||||
routingInfo.setCurrentStep(
|
||||
steps.first(),
|
||||
stepRemainingDistance
|
||||
)
|
||||
.setOnClickListener {
|
||||
stopNavigation()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
if (shouldShowNextStep && steps.size > 1) {
|
||||
routingInfo.setNextStep(steps[1])
|
||||
}
|
||||
return routingInfo.build()
|
||||
}
|
||||
|
||||
private fun navigateAction(): Action {
|
||||
navigationType = NavigationType.NAVIGATION
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.assistant_navigation_48px
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
val navigateTo = location(recentPlace.longitude, recentPlace.latitude)
|
||||
viewModel.loadRoute(carContext, surfaceRenderer.lastLocation, navigateTo)
|
||||
routeModel.routeState = routeModel.routeState.copy(destination = recentPlace)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
RoutePreviewType.SINGLE_ROUTE,
|
||||
surfaceRenderer,
|
||||
place,
|
||||
navigationViewModel,
|
||||
showAlternativeRoute
|
||||
)
|
||||
) { obj: Any? ->
|
||||
if (obj != null) {
|
||||
listener.navigateToPlace(place)
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action to close the current view or template.
|
||||
*/
|
||||
private fun closeAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_close_white_24dp
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
return createAction(
|
||||
carContext, R.drawable.ic_close_white_24dp,
|
||||
onClickAction = {
|
||||
navigationType = NavigationType.VIEW
|
||||
invalidate()
|
||||
}
|
||||
.setFlags(FLAG_DEFAULT)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun searchAction(): Action {
|
||||
return Action.Builder()
|
||||
.setIcon(routeModel.createCarIcon(carContext, R.drawable.ic_search_black36dp))
|
||||
.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_24px))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
stopNavigation()
|
||||
routeState = routeState.copy(arrived = true)
|
||||
surfaceRenderer.routeData.value = ""
|
||||
navigationType = NavigationType.ARRIVAL
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateSpeedCamera(location: Location) {
|
||||
if (lastCameraSearch++ % 100 == 0) {
|
||||
viewModel.getSpeedCameras(location)
|
||||
}
|
||||
if (speedCameras.isNotEmpty()) {
|
||||
updateDistance(location)
|
||||
reRouteTimer?.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDistance(
|
||||
location: Location,
|
||||
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible states for the navigation UI.
|
||||
*/
|
||||
enum class NavigationType {
|
||||
VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL
|
||||
}
|
||||
}
|
||||
|
||||