mirror of
https://github.com/ev-map/EVMap.git
synced 2026-01-05 13:37:48 -05:00
Compare commits
23 Commits
1.9.3
...
car-app-li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c5b72b22 | ||
|
|
eb74367d15 | ||
|
|
3fb290b67e | ||
|
|
398f159e27 | ||
|
|
6ab3ba2ed2 | ||
|
|
f59fd9b3aa | ||
|
|
9e18c62d9d | ||
|
|
3626c9a72f | ||
|
|
36805d8224 | ||
|
|
b1dee90068 | ||
|
|
dfc7de75ad | ||
|
|
32c7774a3a | ||
|
|
02ef25b961 | ||
|
|
e535e77b7a | ||
|
|
5b0b4e4337 | ||
|
|
a6bbf635c5 | ||
|
|
591f99dea4 | ||
|
|
0c5bd69205 | ||
|
|
72e98cf611 | ||
|
|
0fefffda2f | ||
|
|
49e555ef04 | ||
|
|
d6d1e915ee | ||
|
|
546d7a11ce |
@@ -46,8 +46,7 @@ features and how they can be obtained in our [documentation page](doc/api_keys.m
|
||||
There are four different build flavors, `googleNormal`, `fossNormal`, `googleAutomotive`, and
|
||||
`fossAutomotive`.
|
||||
|
||||
- The `foss` variants only use OSM data and should run on most Android devices, even without
|
||||
Google Play Services.
|
||||
- The `foss` variants only use OSM data for the base map and place search. They should run on most Android devices, even those without Google Play Services.
|
||||
- `fossNormal` is intended to run on smartphones and tablets, and also includes the Android
|
||||
Auto app for use on the car display (however Android Auto may not work if the app is not
|
||||
installed from Google Play, see https://github.com/ev-map/EVMap/issues/319).
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode = 224
|
||||
versionName = "1.9.3"
|
||||
versionCode = 230
|
||||
versionName = "1.9.6"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -306,23 +306,26 @@ dependencies {
|
||||
implementation("com.google.guava:guava:29.0-android")
|
||||
implementation("com.github.pengrad:mapscaleview:1.6.0")
|
||||
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
|
||||
implementation("com.github.erfansn:locale-config-x:1.0.0")
|
||||
implementation("com.github.erfansn:locale-config-x:1.0.1")
|
||||
|
||||
// Android Auto
|
||||
val carAppVersion = "1.4.0"
|
||||
val carAppVersion = "1.7.0-beta01"
|
||||
implementation("androidx.car.app:app:$carAppVersion")
|
||||
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
|
||||
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
|
||||
|
||||
// AnyMaps
|
||||
val anyMapsVersion = "1e00650bc7"
|
||||
val anyMapsVersion = "010de4e275"
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
|
||||
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
|
||||
googleImplementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||
googleImplementation("com.google.android.gms:play-services-maps:19.0.0")
|
||||
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
|
||||
// duplicates classes from mapbox-sdk-services
|
||||
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||
}
|
||||
implementation("org.maplibre.gl:android-sdk:10.3.2-pre3") {
|
||||
exclude("org.maplibre.gl", "android-sdk-geojson")
|
||||
}
|
||||
|
||||
// Google Places
|
||||
googleImplementation("com.google.android.libraries.places:places:3.5.0")
|
||||
@@ -345,10 +348,7 @@ dependencies {
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
implementation("androidx.room:room-ktx:$room_version")
|
||||
implementation("com.github.anboralabs:spatia-room:0.2.9") {
|
||||
exclude(group = "com.github.dalgarins", module = "android-spatialite")
|
||||
}
|
||||
implementation("com.github.EV-map:android-spatialite:e5495c83ad") // version with minSdk increased to 21 & FORTIFY_SOURCE enabled
|
||||
implementation("com.github.anboralabs:spatia-room:0.3.0")
|
||||
|
||||
// billing library
|
||||
val billing_version = "7.0.0"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
|
||||
setContentView(R.layout.activity_maps)
|
||||
|
||||
val drawerLayout = findViewById<DrawerLayout>(R.id.drawer_layout)
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.map,
|
||||
@@ -67,7 +68,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
R.id.about,
|
||||
R.id.settings
|
||||
),
|
||||
findViewById<DrawerLayout>(R.id.drawer_layout)
|
||||
drawerLayout
|
||||
)
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
@@ -87,6 +88,17 @@ class MapsActivity : AppCompatActivity(),
|
||||
|
||||
checkPlayServices(this)
|
||||
|
||||
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
||||
var deepLink: PendingIntent? = null
|
||||
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
if (destination.id == R.id.onboarding) {
|
||||
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
} else {
|
||||
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// wait for splash screen animation to finish on first start
|
||||
@@ -104,134 +116,125 @@ class MapsActivity : AppCompatActivity(),
|
||||
}
|
||||
})
|
||||
}
|
||||
navGraph.setStartDestination(R.id.onboarding)
|
||||
navController.graph = navGraph
|
||||
return
|
||||
} else if (!prefs.privacyAccepted) {
|
||||
navGraph.setStartDestination(R.id.onboarding)
|
||||
navController.graph = navGraph
|
||||
return
|
||||
} else {
|
||||
navGraph.setStartDestination(R.id.map)
|
||||
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
||||
var deepLink: PendingIntent? = null
|
||||
} else if (intent?.scheme == "geo") {
|
||||
val query = intent.data?.query?.split("=")?.get(1)
|
||||
val coords = getLocationFromIntent(intent)
|
||||
|
||||
if (intent?.scheme == "geo") {
|
||||
val query = intent.data?.query?.split("=")?.get(1)
|
||||
val coords = getLocationFromIntent(intent)
|
||||
|
||||
if (coords != null) {
|
||||
val lat = coords[0]
|
||||
val lon = coords[1]
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||
.createPendingIntent()
|
||||
} else if (!query.isNullOrEmpty()) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = query).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "goingelectric") {
|
||||
prefs.dataSource = "goingelectric"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_goingelectric)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host in listOf("openchargemap.org", "map.openchargemap.io")) {
|
||||
val id = when (intent.data?.host) {
|
||||
"openchargemap.org" -> intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "openchargemap") {
|
||||
prefs.dataSource = "openchargemap"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_openchargemap)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent.scheme == "net.vonforst.evmap") {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
latLng = LatLng(lat, lon),
|
||||
locationName = name
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
} else if (name != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
if (coords != null) {
|
||||
val lat = coords[0]
|
||||
val lon = coords[1]
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
latLng = LatLng(
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
).toBundle()
|
||||
)
|
||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||
} else if (!query.isNullOrEmpty()) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.favs)
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_DONATE)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.donate)
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = query).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
deepLink?.send()
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||
val id = intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "goingelectric") {
|
||||
prefs.dataSource = "goingelectric"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_goingelectric)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host in listOf(
|
||||
"openchargemap.org",
|
||||
"map.openchargemap.io"
|
||||
)
|
||||
) {
|
||||
val id = when (intent.data?.host) {
|
||||
"openchargemap.org" -> intent.data?.pathSegments?.lastOrNull()?.toLongOrNull()
|
||||
"map.openchargemap.io" -> intent.data?.getQueryParameter("id")?.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "openchargemap") {
|
||||
prefs.dataSource = "openchargemap"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_openchargemap)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent.scheme == "net.vonforst.evmap") {
|
||||
intent.data?.let {
|
||||
if (it.host == "find_charger") {
|
||||
val lat = it.getQueryParameter("latitude")?.toDouble()
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
latLng = LatLng(lat, lon),
|
||||
locationName = name
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
} else if (name != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = name).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
latLng = LatLng(
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
).toBundle()
|
||||
)
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.favs)
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_DONATE)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.donate)
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
deepLink?.send()
|
||||
}
|
||||
|
||||
fun navigateTo(charger: ChargeLocation) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import net.vonforst.evmap.addDebugInterceptors
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
import net.vonforst.evmap.api.await
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.cartesianProduct
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
@@ -18,7 +16,6 @@ import net.vonforst.evmap.viewmodel.Resource
|
||||
import okhttp3.Cache
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.net.CookieManager
|
||||
@@ -41,16 +38,6 @@ interface AvailabilityDetector {
|
||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||
protected val radius = 150 // max radius in meters
|
||||
|
||||
protected suspend fun httpGet(url: String): String {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = client.newCall(request).await()
|
||||
|
||||
if (!response.isSuccessful) throw IOException(response.message)
|
||||
|
||||
val str = response.body!!.string()
|
||||
return str
|
||||
}
|
||||
|
||||
protected fun getCorrespondingChargepoint(
|
||||
cps: Iterable<Chargepoint>, type: String, power: Double
|
||||
): Chargepoint? {
|
||||
|
||||
@@ -52,32 +52,31 @@ class TeslaGuestAvailabilityDetector(
|
||||
|
||||
val (detailsA, guestPricing) = coroutineScope {
|
||||
val details = async {
|
||||
api.getChargingSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
|
||||
api.getSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetSiteDetailsVariables(
|
||||
TeslaChargingGuestGraphQlApi.Identifier(
|
||||
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||
trtId
|
||||
trtId, TeslaChargingGuestGraphQlApi.Experience.ADHOC
|
||||
)
|
||||
),
|
||||
TeslaChargingGuestGraphQlApi.Experience.ADHOC
|
||||
)
|
||||
)
|
||||
).data.site ?: throw AvailabilityDetectorException("no candidates found.")
|
||||
).data.chargingNetwork?.site
|
||||
?: throw AvailabilityDetectorException("no candidates found.")
|
||||
}
|
||||
val guestPricing = async {
|
||||
api.getChargingSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetChargingSiteInformationVariables(
|
||||
api.getSiteDetails(
|
||||
TeslaChargingGuestGraphQlApi.GetSiteDetailsRequest(
|
||||
TeslaChargingGuestGraphQlApi.GetSiteDetailsVariables(
|
||||
TeslaChargingGuestGraphQlApi.Identifier(
|
||||
TeslaChargingGuestGraphQlApi.ChargingSiteIdentifier(
|
||||
trtId
|
||||
trtId, TeslaChargingGuestGraphQlApi.Experience.GUEST
|
||||
)
|
||||
),
|
||||
TeslaChargingGuestGraphQlApi.Experience.GUEST
|
||||
)
|
||||
)
|
||||
).data.site?.pricing
|
||||
).data.chargingNetwork?.site?.pricing
|
||||
}
|
||||
details to guestPricing
|
||||
}
|
||||
@@ -103,12 +102,9 @@ class TeslaGuestAvailabilityDetector(
|
||||
"charger has unknown connectors"
|
||||
)
|
||||
|
||||
val chargerDetails = details.chargersAvailable.chargerDetails
|
||||
val chargers = details.chargers.associateBy { it.id }
|
||||
var detailsSorted = chargerDetails
|
||||
.sortedBy { chargers[it.id]?.labelLetter }
|
||||
.sortedBy { chargers[it.id]?.labelNumber }
|
||||
|
||||
var detailsSorted = details.chargerList
|
||||
.sortedBy { c -> c.labelLetter }
|
||||
.sortedBy { c -> c.labelNumber }
|
||||
|
||||
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
|
||||
// apparently some connectors are missing in Tesla data
|
||||
@@ -120,7 +116,7 @@ class TeslaGuestAvailabilityDetector(
|
||||
detailsSorted + List(numMissing) {
|
||||
TeslaChargingGuestGraphQlApi.ChargerDetail(
|
||||
ChargerAvailability.UNKNOWN,
|
||||
""
|
||||
"", ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -151,7 +147,7 @@ class TeslaGuestAvailabilityDetector(
|
||||
}
|
||||
|
||||
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
|
||||
val labelsMap = detailsMap.mapValues { it.value.map { chargers[it.id]?.label } }
|
||||
val labelsMap = detailsMap.mapValues { it.value.map { it.label } }
|
||||
|
||||
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package net.vonforst.evmap.api.availability.tesla
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
@@ -15,7 +11,6 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import java.lang.reflect.Type
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface TeslaCuaApi {
|
||||
@@ -71,24 +66,22 @@ interface TeslaCuaApi {
|
||||
|
||||
interface TeslaChargingGuestGraphQlApi {
|
||||
@POST("graphql")
|
||||
suspend fun getChargingSiteDetails(
|
||||
@Body request: GetChargingSiteDetailsRequest,
|
||||
@Query("operationName") operationName: String = "getGuestChargingSiteDetails"
|
||||
suspend fun getSiteDetails(
|
||||
@Body request: GetSiteDetailsRequest,
|
||||
@Query("operationName") operationName: String = "GetSiteDetails"
|
||||
): GetChargingSiteDetailsResponse
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsRequest(
|
||||
override val variables: GetChargingSiteInformationVariables,
|
||||
override val operationName: String = "getGuestChargingSiteDetails",
|
||||
data class GetSiteDetailsRequest(
|
||||
override val variables: GetSiteDetailsVariables,
|
||||
override val operationName: String = "GetSiteDetails",
|
||||
override val query: String =
|
||||
"\n query getGuestChargingSiteDetails(\$identifier: ChargingSiteIdentifierInput!, \$deviceLocale: String!, \$experience: ChargingExperienceEnum!) {\n site(\n identifier: \$identifier\n deviceLocale: \$deviceLocale\n experience: \$experience\n ) {\n activeOutages\n address {\n countryCode\n }\n chargers {\n id\n label\n }\n chargersAvailable {\n chargerDetails {\n id\n availability\n }\n }\n holdAmount {\n holdAmount\n currencyCode\n }\n maxPowerKw\n name\n programType\n publicStallCount\n id\n pricing(experience: \$experience) {\n userRates {\n activePricebook {\n charging {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n parking {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n congestion {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n }\n }\n }\n }\n }\n}\n "
|
||||
"\n query GetSiteDetails(\$siteId: SiteIdInput!) {\n chargingNetwork {\n site(siteId: \$siteId) {\n address {\n countryCode\n }\n chargerList {\n id\n label\n availability\n }\n holdAmount {\n amount\n currencyCode\n }\n maxPowerKw\n name\n programType\n publicStallCount\n trtId\n pricing {\n userRates {\n activePricebook {\n charging {\n ...ChargingRate\n }\n parking {\n ...ChargingRate\n }\n congestion {\n ...ChargingRate\n }\n }\n }\n }\n }\n }\n}\n \n fragment ChargingRate on ChargingUserRate {\n uom\n rates\n buckets {\n start\n end\n }\n bucketUom\n currencyCode\n programType\n vehicleMakeType\n touRates {\n enabled\n activeRatesByTime {\n startTime\n endTime\n rates\n }\n }\n}\n "
|
||||
) : GraphQlRequest()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteInformationVariables(
|
||||
val identifier: Identifier,
|
||||
val experience: Experience,
|
||||
val deviceLocale: String = "de-DE",
|
||||
data class GetSiteDetailsVariables(
|
||||
val siteId: Identifier,
|
||||
)
|
||||
|
||||
enum class Experience {
|
||||
@@ -97,22 +90,22 @@ interface TeslaChargingGuestGraphQlApi {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Identifier(
|
||||
val siteId: ChargingSiteIdentifier
|
||||
val byTrtId: ChargingSiteIdentifier
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteIdentifier(
|
||||
val id: Long,
|
||||
val siteType: SiteType = SiteType.SUPERCHARGER
|
||||
val trtId: Long,
|
||||
val chargingExperience: Experience,
|
||||
val programType: String = "PTSCH",
|
||||
val locale: String = "de-DE",
|
||||
)
|
||||
|
||||
enum class SiteType {
|
||||
@Json(name = "SITE_TYPE_SUPERCHARGER")
|
||||
SUPERCHARGER
|
||||
}
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsResponse(val data: GetChargingSiteDetailsResponseDataNetwork)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsResponse(val data: GetChargingSiteDetailsResponseData)
|
||||
data class GetChargingSiteDetailsResponseDataNetwork(val chargingNetwork: GetChargingSiteDetailsResponseData?)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GetChargingSiteDetailsResponseData(val site: ChargingSiteInformation?)
|
||||
@@ -120,9 +113,8 @@ interface TeslaChargingGuestGraphQlApi {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargingSiteInformation(
|
||||
val activeOutages: List<Outage>?,
|
||||
val chargers: List<ChargerId>,
|
||||
val chargersAvailable: ChargersAvailable,
|
||||
val id: Long,
|
||||
val chargerList: List<ChargerDetail>,
|
||||
val trtId: Long,
|
||||
val maxPowerKw: Int,
|
||||
val name: String,
|
||||
val pricing: Pricing,
|
||||
@@ -130,9 +122,10 @@ interface TeslaChargingGuestGraphQlApi {
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerId(
|
||||
val id: String,
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val label: String?,
|
||||
val id: String
|
||||
) {
|
||||
val labelNumber
|
||||
get() = label?.replace(Regex("""\D"""), "")?.toInt()
|
||||
@@ -140,15 +133,6 @@ interface TeslaChargingGuestGraphQlApi {
|
||||
get() = label?.replace(Regex("""\d"""), "")
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargersAvailable(val chargerDetails: List<ChargerDetail>)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChargerDetail(
|
||||
val availability: ChargerAvailability,
|
||||
val id: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
client: OkHttpClient,
|
||||
|
||||
@@ -452,7 +452,10 @@ class GoingElectricApiWrapper(
|
||||
if (responses.map { it.isSuccessful }.all { it }
|
||||
&& plugsResponse.body()!!.status == STATUS_OK
|
||||
&& chargeCardsResponse.body()!!.status == STATUS_OK
|
||||
&& networksResponse.body()!!.status == STATUS_OK) {
|
||||
&& networksResponse.body()!!.status == STATUS_OK
|
||||
&& plugsResponse.body()!!.result != null
|
||||
&& chargeCardsResponse.body()!!.result != null
|
||||
&& networksResponse.body()!!.result != null) {
|
||||
Resource.success(
|
||||
GEReferenceData(
|
||||
plugsResponse.body()!!.result!!,
|
||||
|
||||
@@ -254,7 +254,7 @@ data class OCMUserComment(
|
||||
@Json(name = "ID") val id: Long,
|
||||
@Json(name = "CommentTypeID") val commentTypeId: Long,
|
||||
@Json(name = "Comment") val comment: String?,
|
||||
@Json(name = "UserName") val userName: String,
|
||||
@Json(name = "UserName") val userName: String?,
|
||||
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
@@ -125,8 +126,11 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
|
||||
val mapScreen = MapScreen(carContext, this)
|
||||
val mapScreen = if (supportsNewMapScreen(carContext)) {
|
||||
MapScreen(carContext, this)
|
||||
} else {
|
||||
LegacyMapScreen(carContext, this)
|
||||
}
|
||||
val screens = mutableListOf<Screen>(mapScreen)
|
||||
|
||||
handleActionsIntent(intent)?.let {
|
||||
@@ -186,7 +190,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
val lon = it.getQueryParameter("longitude")?.toDouble()
|
||||
val name = it.getQueryParameter("name")
|
||||
if (lat != null && lon != null) {
|
||||
prefs.placeSearchResultAndroidAuto = LatLng(lat, lon)
|
||||
prefs.placeSearchResultAndroidAuto = PlaceWithBounds(LatLng(lat, lon), null)
|
||||
prefs.placeSearchResultAndroidAutoName = name ?: "%.4f,%.4f".format(lat, lon)
|
||||
return null
|
||||
} else if (name != null) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package net.vonforst.evmap.auto
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
@@ -13,7 +12,6 @@ import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.HostException
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.Action
|
||||
@@ -53,9 +51,6 @@ import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.fronyx.FronyxApi
|
||||
import net.vonforst.evmap.api.fronyx.PredictionData
|
||||
import net.vonforst.evmap.api.fronyx.PredictionRepository
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Cost
|
||||
import net.vonforst.evmap.model.FaultReport
|
||||
@@ -65,7 +60,6 @@ import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
@@ -75,7 +69,6 @@ import java.time.format.FormatStyle
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
|
||||
var charger: ChargeLocation? = null
|
||||
var photo: Bitmap? = null
|
||||
@@ -135,7 +128,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
.setFlags(Action.FLAG_PRIMARY)
|
||||
.setBackgroundColor(CarColor.PRIMARY)
|
||||
.setOnClickListener {
|
||||
navigateToCharger(charger)
|
||||
navigateToCharger(carContext, charger)
|
||||
}
|
||||
.build())
|
||||
if (ChargepriceApi.isChargerSupported(charger)) {
|
||||
@@ -272,7 +265,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
addText(generateChargepointsText(charger))
|
||||
addText(generateChargepointsText(charger, availability, carContext))
|
||||
}.build())
|
||||
if (maxRows <= 3) {
|
||||
// row 2: operator + cost + fault report
|
||||
@@ -485,47 +478,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
return string
|
||||
}
|
||||
|
||||
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
chargepointsText.apply {
|
||||
if (i > 0) append(" · ")
|
||||
append("${cp.count}× ")
|
||||
val plugIcon = iconForPlugType(cp.type)
|
||||
if (plugIcon != 0) {
|
||||
append(
|
||||
nameForPlugType(carContext.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
plugIcon
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
} else {
|
||||
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
||||
}
|
||||
cp.formatPower()?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return chargepointsText
|
||||
}
|
||||
|
||||
private fun generateOperatorText(charger: ChargeLocation) =
|
||||
if (charger.operator != null && charger.network != null) {
|
||||
if (charger.operator.contains(charger.network)) {
|
||||
@@ -543,22 +495,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
carContext.getString(R.string.unknown_operator)
|
||||
}
|
||||
|
||||
private fun navigateToCharger(charger: ChargeLocation) {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
Intent(
|
||||
CarContext.ACTION_NAVIGATE,
|
||||
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||
)
|
||||
try {
|
||||
carContext.startCarApp(intent)
|
||||
} catch (e: HostException) {
|
||||
CarToast.makeText(carContext, R.string.no_maps_app_found, CarToast.LENGTH_SHORT).show()
|
||||
} catch (ignored: SecurityException) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharger() {
|
||||
lifecycleScope.launch {
|
||||
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.location.Location
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarIconSpan
|
||||
import androidx.car.app.model.CarLocation
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.DistanceSpan
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Metadata
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.Place
|
||||
import androidx.car.app.model.PlaceMarker
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
interface ChargerListDelegate : ItemList.OnItemVisibilityChangedListener {
|
||||
val locationError: Boolean
|
||||
val loadingError: Boolean
|
||||
val maxRows: Int
|
||||
val filterStatus: Long
|
||||
val location: Location?
|
||||
val energyLevel: EnergyLevel?
|
||||
fun onChargerClick(charger: ChargeLocation)
|
||||
}
|
||||
|
||||
class ChargerListFormatter(val carContext: CarContext, val screen: ChargerListDelegate) {
|
||||
private val iconGen = ChargerIconGenerator(carContext, null, height = 96)
|
||||
var favorites: Set<Long> = emptySet()
|
||||
|
||||
fun buildChargerList(
|
||||
chargers: List<ChargeLocation>?,
|
||||
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>
|
||||
): ItemList? {
|
||||
return if (chargers != null) {
|
||||
val chargerList = chargers.take(screen.maxRows)
|
||||
val builder = ItemList.Builder()
|
||||
// only show the city if not all chargers are in the same city
|
||||
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
|
||||
chargerList.forEach { charger ->
|
||||
builder.addItem(
|
||||
formatCharger(
|
||||
charger,
|
||||
availabilities,
|
||||
showCity,
|
||||
charger.id in favorites
|
||||
)
|
||||
)
|
||||
}
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(
|
||||
if (screen.filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_no_favorites_found
|
||||
} else {
|
||||
R.string.auto_no_chargers_found
|
||||
}
|
||||
)
|
||||
)
|
||||
builder.setOnItemsVisibilityChangedListener(screen)
|
||||
builder.build()
|
||||
} else {
|
||||
if (screen.loadingError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.connection_error)
|
||||
)
|
||||
builder.build()
|
||||
} else if (screen.locationError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.location_error)
|
||||
)
|
||||
builder.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCharger(
|
||||
charger: ChargeLocation,
|
||||
availabilities: Map<Long, Pair<ZonedDateTime, ChargeLocationStatus?>>,
|
||||
showCity: Boolean,
|
||||
isFavorite: Boolean
|
||||
): Row {
|
||||
val markerTint = getMarkerTint(charger)
|
||||
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
|
||||
R.color.charger_100kw_dark // slightly darker color for better contrast
|
||||
} else {
|
||||
markerTint
|
||||
}
|
||||
val color = ContextCompat.getColor(carContext, backgroundTint)
|
||||
val place =
|
||||
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.createCustom(color, color))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val icon = iconGen.getBitmap(
|
||||
markerTint,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(),
|
||||
fav = isFavorite
|
||||
)
|
||||
val iconSpan =
|
||||
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
|
||||
|
||||
return Row.Builder().apply {
|
||||
// only show the city if not all chargers are in the same city (-> showCity == true)
|
||||
// and the city is not already contained in the charger name
|
||||
val title = SpannableStringBuilder().apply {
|
||||
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append(charger.name)
|
||||
}
|
||||
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
|
||||
val titleWithCity = SpannableStringBuilder().apply {
|
||||
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append("${charger.name} · ${charger.address.city}")
|
||||
}
|
||||
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
|
||||
} else {
|
||||
setTitle(title)
|
||||
}
|
||||
|
||||
val text = SpannableStringBuilder()
|
||||
|
||||
// distance
|
||||
screen.location?.let {
|
||||
val distanceMeters = distanceBetween(
|
||||
it.latitude, it.longitude,
|
||||
charger.coordinates.lat, charger.coordinates.lng
|
||||
)
|
||||
text.append(
|
||||
"distance",
|
||||
DistanceSpan.create(
|
||||
roundValueToDistance(
|
||||
distanceMeters,
|
||||
screen.energyLevel?.distanceDisplayUnit?.value,
|
||||
carContext
|
||||
)
|
||||
),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
// power
|
||||
val power = charger.maxPower
|
||||
if (power != null) {
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${power.roundToInt()} kW")
|
||||
}
|
||||
|
||||
// availability
|
||||
availabilities[charger.id]?.second?.let { av ->
|
||||
val status = av.status.values.flatten()
|
||||
val available = availabilityText(status)
|
||||
val total = charger.chargepoints.sumOf { it.count }
|
||||
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append(
|
||||
"$available/$total",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
addText(text)
|
||||
setMetadata(
|
||||
Metadata.Builder()
|
||||
.setPlace(place)
|
||||
.build()
|
||||
)
|
||||
|
||||
setOnClickListener {
|
||||
screen.onChargerClick(charger)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
fun buildSingleCharger(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
onClick: () -> Unit
|
||||
) = Pane.Builder().apply {
|
||||
val icon = iconGen.getBitmap(
|
||||
getMarkerTint(charger),
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(),
|
||||
fav = charger.id in favorites
|
||||
)
|
||||
|
||||
|
||||
addRow(Row.Builder().apply {
|
||||
setImage(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
|
||||
setTitle(charger.address.toString())
|
||||
addText(generateChargepointsText(charger, availability, carContext))
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.show_more))
|
||||
setOnClickListener(onClick)
|
||||
}.build())
|
||||
addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).build()
|
||||
)
|
||||
setTitle(carContext.getString(R.string.navigate))
|
||||
setBackgroundColor(CarColor.PRIMARY)
|
||||
setOnClickListener {
|
||||
navigateToCharger(carContext, charger)
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
533
app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
Normal file
533
app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
Normal file
@@ -0,0 +1,533 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.CarInfo
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarLocation
|
||||
import androidx.car.app.model.OnContentRefreshListener
|
||||
import androidx.car.app.model.Place
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.model.PlaceMarker
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterWithValue
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.bearingBetween
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.utils.headingDiff
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
*
|
||||
* Legacy implementation for Car App API level < 7
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class LegacyMapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
|
||||
ChargerListDelegate, DefaultLifecycleObserver {
|
||||
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
private var updateCoroutine: Job? = null
|
||||
private var availabilityUpdateCoroutine: Job? = null
|
||||
|
||||
private var visibleStart: Int? = null
|
||||
private var visibleEnd: Int? = null
|
||||
|
||||
override var location: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var lastChargersUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private val favorites = db.favoritesDao().getAllFavorites()
|
||||
override var loadingError = false
|
||||
override var locationError = false
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private val chargersUpdateThresholdDistance = 500 // meters
|
||||
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
|
||||
HashMap()
|
||||
override val maxRows =
|
||||
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
|
||||
override var filterStatus = prefs.filterStatus
|
||||
|
||||
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
|
||||
|
||||
private val carInfo: CarInfo by lazy {
|
||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||
}
|
||||
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
|
||||
override var energyLevel: EnergyLevel? = null
|
||||
private var heading: Compass? = null
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
"android.car.permission.READ_CAR_DISPLAY_UNITS",
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
)
|
||||
}
|
||||
|
||||
private var searchLocation: LatLng? = null
|
||||
|
||||
private val formatter = ChargerListFormatter(ctx, this)
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
marker = MapScreen.MARKER
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(
|
||||
prefs.placeSearchResultAndroidAutoName?.let {
|
||||
carContext.getString(R.string.auto_chargers_near_location, it)
|
||||
} ?: carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_favorites
|
||||
} else {
|
||||
R.string.auto_chargers_closeby
|
||||
}
|
||||
)
|
||||
)
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
searchLocation?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.PRIMARY)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
} else {
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
|
||||
}
|
||||
}
|
||||
formatter.buildChargerList(chargers, availabilities)?.let {
|
||||
setItemList(it)
|
||||
} ?: setLoading(true)
|
||||
setCurrentLocationEnabled(true)
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
|
||||
filtersWithValue?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
}
|
||||
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(SettingsScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
R.drawable.ic_search_off
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
screenManager.pushForResult(
|
||||
PlaceSearchScreen(
|
||||
carContext,
|
||||
session
|
||||
)
|
||||
) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(FilterScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
if (carContext.carAppApiLevel >= 5 ||
|
||||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
|
||||
) {
|
||||
setOnContentRefreshListener(this@LegacyMapScreen)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun onChargerClick(charger: ChargeLocation) {
|
||||
screenManager.push(ChargerDetailScreen(carContext, charger))
|
||||
session.mapScreen = null
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
if (location.latitude == this.location?.latitude
|
||||
&& location.longitude == this.location?.longitude
|
||||
) {
|
||||
return
|
||||
}
|
||||
val previousLocation = this.location
|
||||
this.location = location
|
||||
if (previousLocation == null) {
|
||||
loadChargers()
|
||||
return
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
if (lastDistanceUpdateTime == null ||
|
||||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
|
||||
) {
|
||||
lastDistanceUpdateTime = now
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// if chargers are searched around current location, consider app-driven refresh
|
||||
val searchLocation =
|
||||
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
|
||||
val distance = searchLocation?.let {
|
||||
distanceBetween(
|
||||
it.latitude, it.longitude, location.latitude, location.longitude
|
||||
)
|
||||
} ?: 0.0
|
||||
if (supportsRefresh && (lastChargersUpdateTime == null ||
|
||||
Duration.between(
|
||||
lastChargersUpdateTime,
|
||||
now
|
||||
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
|
||||
) {
|
||||
onContentRefreshRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChargers() {
|
||||
val location = location ?: return
|
||||
|
||||
val searchLocation =
|
||||
prefs.placeSearchResultAndroidAuto?.latLng ?: LatLng.fromLocation(location)
|
||||
this.searchLocation = searchLocation
|
||||
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
loadingError = false
|
||||
try {
|
||||
filterStatus = prefs.filterStatus
|
||||
val filterValues =
|
||||
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
|
||||
val filters = repo.getFiltersAsync(carContext.stringProvider())
|
||||
filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
val apiId = repo.api.value!!.id
|
||||
|
||||
// load chargers
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
val chargers = favorites.await().map { it.charger }.sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
this@LegacyMapScreen.chargers = chargers
|
||||
} else {
|
||||
// try multiple search radii until we have enough chargers
|
||||
var chargers: List<ChargeLocation>? = null
|
||||
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
|
||||
for (radius in radiusValues) {
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
radius,
|
||||
zoom = 16f,
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
|
||||
loadingError = true
|
||||
this@LegacyMapScreen.chargers = null
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
if (prefs.placeSearchResultAndroidAutoName == null) {
|
||||
chargers = headingFilter(
|
||||
chargers,
|
||||
searchLocation
|
||||
)
|
||||
}
|
||||
if (chargers == null || chargers.size >= maxRows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
this@LegacyMapScreen.chargers = chargers
|
||||
}
|
||||
|
||||
updateCoroutine = null
|
||||
lastChargersUpdateTime = Instant.now()
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
loadingError = true
|
||||
invalidate()
|
||||
} catch (e: HttpException) {
|
||||
loadingError = true
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by heading if heading available and enabled
|
||||
*/
|
||||
private fun headingFilter(
|
||||
chargers: List<ChargeLocation>?,
|
||||
searchLocation: LatLng
|
||||
): List<ChargeLocation>? {
|
||||
// use compass heading if available, otherwise fall back to GPS
|
||||
val location = location
|
||||
val heading = heading?.orientations?.value?.get(0)
|
||||
?: if (location?.hasBearing() == true) location.bearing else null
|
||||
return heading?.let {
|
||||
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
|
||||
|
||||
chargers?.filter {
|
||||
val bearing = bearingBetween(
|
||||
searchLocation.latitude,
|
||||
searchLocation.longitude,
|
||||
it.coordinates.lat,
|
||||
it.coordinates.lng
|
||||
)
|
||||
val diff = headingDiff(bearing, heading.toDouble())
|
||||
abs(diff) < 30
|
||||
}
|
||||
} ?: chargers
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
this.energyLevel = energyLevel
|
||||
if (isUpdate) invalidate()
|
||||
}
|
||||
|
||||
private fun onCompassUpdated(compass: Compass) {
|
||||
this.heading = compass
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
setupListeners()
|
||||
session.requestLocationUpdates()
|
||||
locationError = false
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (location == null) {
|
||||
locationError = true
|
||||
invalidate()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
if (prefs.dataSource != repo.api.value?.id) {
|
||||
repo.api.value = createApi(prefs.dataSource, carContext)
|
||||
}
|
||||
invalidate()
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
carSensors.addCompassListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
exec,
|
||||
::onCompassUpdated
|
||||
)
|
||||
}
|
||||
if (!permissions.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
})
|
||||
return
|
||||
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Setting up energy level listener")
|
||||
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
// Deleting the data already in onStop makes sure that we show a loading screen directly
|
||||
// (i.e. onGetTemplate is not called while the old data is still there)
|
||||
chargers = null
|
||||
availabilities.clear()
|
||||
location = null
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
private fun removeListeners() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Removing energy level listener")
|
||||
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
carSensors.removeCompassListener(::onCompassUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentRefreshRequested() {
|
||||
loadChargers()
|
||||
availabilities.clear()
|
||||
|
||||
val start = visibleStart
|
||||
val end = visibleEnd
|
||||
if (start != null && end != null) {
|
||||
onItemVisibilityChanged(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
|
||||
if (startIndex == -1 || endIndex == -1) return
|
||||
if (availabilityUpdateCoroutine != null) return
|
||||
|
||||
visibleEnd = endIndex
|
||||
visibleStart = startIndex
|
||||
|
||||
// remove outdated availabilities
|
||||
availabilities = availabilities.filter {
|
||||
Duration.between(
|
||||
it.value.first,
|
||||
ZonedDateTime.now()
|
||||
) <= availabilityUpdateThreshold
|
||||
}.toMutableMap()
|
||||
|
||||
// update availabilities
|
||||
availabilityUpdateCoroutine = lifecycleScope.launch {
|
||||
delay(300L)
|
||||
|
||||
val chargers = chargers ?: return@launch
|
||||
if (chargers.isEmpty()) return@launch
|
||||
|
||||
val tasks = chargers.subList(
|
||||
min(startIndex, chargers.size - 1),
|
||||
min(endIndex, chargers.size - 1)
|
||||
).mapNotNull {
|
||||
// update only if not yet stored
|
||||
if (!availabilities.containsKey(it.id)) {
|
||||
lifecycleScope.async {
|
||||
val availability = availabilityRepo.getAvailability(it).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[it.id] = date to availability
|
||||
}
|
||||
} else null
|
||||
}
|
||||
if (tasks.isNotEmpty()) {
|
||||
tasks.awaitAll()
|
||||
invalidate()
|
||||
}
|
||||
availabilityUpdateCoroutine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.ParkedOnlyOnClickListener
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Template
|
||||
import com.car2go.maps.AttributionClickListener
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
class MapAttributionScreen(
|
||||
ctx: CarContext,
|
||||
val attributions: List<AttributionClickListener.Attribution>
|
||||
) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder()
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setStartHeaderAction(Action.BACK)
|
||||
.setTitle(carContext.getString(R.string.maplibre_attributionsDialogTitle))
|
||||
.build()
|
||||
)
|
||||
.setSingleList(ItemList.Builder().apply {
|
||||
attributions.forEach { attr ->
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(attr.title)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(
|
||||
ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, attr.url)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +1,44 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.car.app.AppManager
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.annotations.RequiresCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.CarInfo
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.MessageTemplate
|
||||
import androidx.car.app.model.PaneTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.navigation.model.MapController
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
@@ -30,19 +46,18 @@ import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.model.FilterValue
|
||||
import net.vonforst.evmap.model.FilterWithValue
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.ChargeLocationsRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.bearingBetween
|
||||
import net.vonforst.evmap.ui.MarkerManager
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.utils.headingDiff
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.await
|
||||
import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import retrofit2.HttpException
|
||||
@@ -51,58 +66,62 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
* Main map screen showing either nearby chargers or favorites.
|
||||
*
|
||||
* New implementation for Car App API Level >= 7 with interactive map using MapSurfaceCallback
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
@RequiresCarApi(7)
|
||||
@ExperimentalCarApi
|
||||
class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
|
||||
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
|
||||
Screen(ctx), LocationAwareScreen, ChargerListDelegate,
|
||||
DefaultLifecycleObserver, OnMapReadyCallback {
|
||||
companion object {
|
||||
val MARKER = "map"
|
||||
}
|
||||
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
|
||||
private var updateCoroutine: Job? = null
|
||||
private var availabilityUpdateCoroutine: Job? = null
|
||||
|
||||
private var visibleStart: Int? = null
|
||||
private var visibleEnd: Int? = null
|
||||
|
||||
private var location: Location? = null
|
||||
override var location: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var lastChargersUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private var isFavorite: List<Boolean>? = null
|
||||
private var loadingError = false
|
||||
private var locationError = false
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
private val searchRadius = 5 // kilometers
|
||||
private var chargers: List<ChargepointListItem>? = null
|
||||
private var selectedCharger: ChargeLocation? = null
|
||||
private val favorites = db.favoritesDao().getAllFavorites()
|
||||
|
||||
override var loadingError = false
|
||||
override val locationError = false
|
||||
|
||||
private val mapSurfaceCallback = MapSurfaceCallback(carContext, lifecycleScope)
|
||||
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private val chargersUpdateThresholdDistance = 500 // meters
|
||||
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
|
||||
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
|
||||
HashMap()
|
||||
private val maxRows =
|
||||
override val maxRows =
|
||||
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
|
||||
private var filterStatus = prefs.filterStatus
|
||||
override var filterStatus = prefs.filterStatus
|
||||
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
|
||||
|
||||
private val carInfo: CarInfo by lazy {
|
||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||
}
|
||||
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
override var energyLevel: EnergyLevel? = null
|
||||
|
||||
private var heading: Compass? = null
|
||||
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
listOf(
|
||||
@@ -116,280 +135,234 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
)
|
||||
}
|
||||
|
||||
private var searchLocation: LatLng? = null
|
||||
private var map: AnyMap? = null
|
||||
private var markerManager: MarkerManager? = null
|
||||
private var myLocationEnabled = false
|
||||
private var myLocationNeedsUpdate = false
|
||||
|
||||
private val iconGen =
|
||||
ChargerIconGenerator(carContext, null, height = 96)
|
||||
private val formatter = ChargerListFormatter(ctx, this)
|
||||
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
clearSelectedCharger()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
marker = MARKER
|
||||
|
||||
favorites.observe(this) {
|
||||
val favoriteIds = it.map { it.favorite.chargerId }.toSet()
|
||||
markerManager?.favorites = favoriteIds
|
||||
formatter.favorites = favoriteIds
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
carContext.getCarService(AppManager::class.java)
|
||||
.setSurfaceCallback(mapSurfaceCallback)
|
||||
|
||||
carContext.onBackPressedDispatcher.addCallback(this, backPressedCallback)
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(
|
||||
prefs.placeSearchResultAndroidAutoName?.let {
|
||||
carContext.getString(R.string.auto_chargers_near_location, it)
|
||||
} ?: carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_favorites
|
||||
} else {
|
||||
R.string.auto_chargers_closeby
|
||||
}
|
||||
)
|
||||
)
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
searchLocation?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
|
||||
if (prefs.placeSearchResultAndroidAutoName != null) {
|
||||
setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.PRIMARY)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
val map = map
|
||||
|
||||
val title = prefs.placeSearchResultAndroidAutoName ?: carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_favorites
|
||||
} else if (myLocationEnabled) {
|
||||
R.string.auto_chargers_closeby
|
||||
} else {
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
|
||||
}
|
||||
}
|
||||
chargers?.take(maxRows)?.let { chargerList ->
|
||||
val builder = ItemList.Builder()
|
||||
// only show the city if not all chargers are in the same city
|
||||
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
|
||||
chargerList.forEachIndexed { i, charger ->
|
||||
builder.addItem(formatCharger(charger, showCity, isFavorite?.get(i) ?: false))
|
||||
}
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
R.string.auto_no_favorites_found
|
||||
} else {
|
||||
R.string.auto_no_chargers_found
|
||||
}
|
||||
)
|
||||
)
|
||||
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
|
||||
setItemList(builder.build())
|
||||
} ?: run {
|
||||
if (loadingError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.connection_error)
|
||||
)
|
||||
setItemList(builder.build())
|
||||
} else if (locationError) {
|
||||
val builder = ItemList.Builder()
|
||||
builder.setNoItemsMessage(
|
||||
carContext.getString(R.string.location_error)
|
||||
)
|
||||
setItemList(builder.build())
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
setCurrentLocationEnabled(true)
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
|
||||
filtersWithValue?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
R.string.app_name
|
||||
}
|
||||
)
|
||||
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(SettingsScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
R.drawable.ic_search_off
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
).build()
|
||||
val actionStrip = buildActionStrip()
|
||||
val selectedCharger = selectedCharger
|
||||
|
||||
)
|
||||
setOnClickListener {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
chargers = null
|
||||
isFavorite = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
chargers = null
|
||||
isFavorite = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
screenManager.pushForResult(
|
||||
PlaceSearchScreen(
|
||||
carContext,
|
||||
session
|
||||
)
|
||||
) {
|
||||
chargers = null
|
||||
isFavorite = null
|
||||
loadChargers()
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
}
|
||||
val contentTemplate = if (selectedCharger != null) {
|
||||
PaneTemplate.Builder(
|
||||
formatter.buildSingleCharger(
|
||||
selectedCharger,
|
||||
availabilities.get(selectedCharger.id)?.second
|
||||
) {
|
||||
screenManager.push(ChargerDetailScreen(carContext, selectedCharger))
|
||||
session.mapScreen = null
|
||||
}).apply {
|
||||
setHeader(Header.Builder().apply {
|
||||
setTitle(selectedCharger.name)
|
||||
setStartHeaderAction(Action.BACK)
|
||||
}.build())
|
||||
}.build()
|
||||
} else if (chargers?.filterIsInstance<ChargeLocationCluster>()?.isNotEmpty() == true) {
|
||||
MessageTemplate.Builder(carContext.getString(R.string.auto_zoom_for_details))
|
||||
.apply {
|
||||
setHeader(Header.Builder().apply {
|
||||
setTitle(title)
|
||||
setStartHeaderAction(Action.APP_ICON)
|
||||
}.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(FilterScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
if (carContext.carAppApiLevel >= 5 ||
|
||||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
|
||||
) {
|
||||
setOnContentRefreshListener(this@MapScreen)
|
||||
}
|
||||
}.build()
|
||||
} else {
|
||||
ListTemplate.Builder().apply {
|
||||
setHeader(Header.Builder().apply {
|
||||
setTitle(title)
|
||||
setStartHeaderAction(Action.APP_ICON)
|
||||
}.build())
|
||||
|
||||
formatter.buildChargerList(
|
||||
chargers?.filterIsInstance<ChargeLocation>(),
|
||||
availabilities
|
||||
)?.let {
|
||||
setSingleList(it)
|
||||
} ?: setLoading(true)
|
||||
}.build()
|
||||
}
|
||||
return MapWithContentTemplate.Builder().apply {
|
||||
setContentTemplate(contentTemplate)
|
||||
setActionStrip(actionStrip)
|
||||
setMapController(MapController.Builder().apply {
|
||||
setMapActionStrip(buildMapActionStrip())
|
||||
setPanModeListener { }
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun formatCharger(
|
||||
charger: ChargeLocation,
|
||||
showCity: Boolean,
|
||||
isFavorite: Boolean
|
||||
): Row {
|
||||
val markerTint = getMarkerTint(charger)
|
||||
val backgroundTint = if ((charger.maxPower ?: 0.0) > 100) {
|
||||
R.color.charger_100kw_dark // slightly darker color for better contrast
|
||||
} else {
|
||||
markerTint
|
||||
}
|
||||
val color = ContextCompat.getColor(carContext, backgroundTint)
|
||||
val place =
|
||||
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.setMarker(
|
||||
PlaceMarker.Builder()
|
||||
.setColor(CarColor.createCustom(color, color))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val icon = iconGen.getBitmap(
|
||||
markerTint,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(),
|
||||
fav = isFavorite
|
||||
)
|
||||
val iconSpan =
|
||||
CarIconSpan.create(CarIcon.Builder(IconCompat.createWithBitmap(icon)).build())
|
||||
|
||||
return Row.Builder().apply {
|
||||
// only show the city if not all chargers are in the same city (-> showCity == true)
|
||||
// and the city is not already contained in the charger name
|
||||
val title = SpannableStringBuilder().apply {
|
||||
append(" ", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append(charger.name)
|
||||
}
|
||||
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
|
||||
val titleWithCity = SpannableStringBuilder().apply {
|
||||
append("", iconSpan, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append(" ")
|
||||
append("${charger.name} · ${charger.address.city}")
|
||||
}
|
||||
setTitle(CarText.Builder(titleWithCity).addVariant(title).build())
|
||||
} else {
|
||||
setTitle(title)
|
||||
}
|
||||
|
||||
val text = SpannableStringBuilder()
|
||||
|
||||
// distance
|
||||
location?.let {
|
||||
val distanceMeters = distanceBetween(
|
||||
it.latitude, it.longitude,
|
||||
charger.coordinates.lat, charger.coordinates.lng
|
||||
)
|
||||
text.append(
|
||||
"distance",
|
||||
DistanceSpan.create(
|
||||
roundValueToDistance(
|
||||
distanceMeters,
|
||||
energyLevel?.distanceDisplayUnit?.value,
|
||||
carContext
|
||||
)
|
||||
),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
// power
|
||||
val power = charger.maxPower
|
||||
if (power != null) {
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append("${power.roundToInt()} kW")
|
||||
}
|
||||
|
||||
// availability
|
||||
availabilities[charger.id]?.second?.let { av ->
|
||||
val status = av.status.values.flatten()
|
||||
val available = availabilityText(status)
|
||||
val total = charger.chargepoints.sumOf { it.count }
|
||||
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append(
|
||||
"$available/$total",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
addText(text)
|
||||
setMetadata(
|
||||
Metadata.Builder()
|
||||
.setPlace(place)
|
||||
private fun buildMapActionStrip() = ActionStrip.Builder()
|
||||
.addAction(Action.PAN)
|
||||
.addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_location))
|
||||
.setTint(if (myLocationEnabled) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
).setOnClickListener {
|
||||
enableLocation(true)
|
||||
}.build()
|
||||
)
|
||||
.addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_add
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
).setOnClickListener {
|
||||
val map = map ?: return@setOnClickListener
|
||||
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(0.5f))
|
||||
}.build()
|
||||
)
|
||||
.addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_remove
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
).setOnClickListener {
|
||||
val map = map ?: return@setOnClickListener
|
||||
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(-0.5f))
|
||||
}.build()
|
||||
).build()
|
||||
|
||||
setOnClickListener {
|
||||
screenManager.push(ChargerDetailScreen(carContext, charger))
|
||||
session.mapScreen = null
|
||||
private fun buildActionStrip(): ActionStrip {
|
||||
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
|
||||
filtersWithValue?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
return ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_settings
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(SettingsScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.addAction(Action.Builder().apply {
|
||||
setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
R.drawable.ic_search_off
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
markerManager?.searchResult = null
|
||||
invalidate()
|
||||
} else {
|
||||
screenManager.pushForResult(
|
||||
PlaceSearchScreen(
|
||||
carContext,
|
||||
session
|
||||
)
|
||||
) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(FilterScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onChargerClick(charger: ChargeLocation) {
|
||||
selectedCharger = charger
|
||||
markerManager?.highlighedCharger = charger
|
||||
markerManager?.animateBounce(charger)
|
||||
backPressedCallback.isEnabled = true
|
||||
invalidate()
|
||||
// load availability
|
||||
lifecycleScope.launch {
|
||||
val availability = availabilityRepo.getAvailability(charger).data
|
||||
val date = ZonedDateTime.now()
|
||||
availabilities[charger.id] = date to availability
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelectedCharger() {
|
||||
selectedCharger = null
|
||||
markerManager?.highlighedCharger = null
|
||||
backPressedCallback.isEnabled = false
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
@@ -398,11 +371,25 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
) {
|
||||
return
|
||||
}
|
||||
val previousLocation = this.location
|
||||
val oldLoc = this.location?.let { LatLng.fromLocation(it) }
|
||||
val latLng = LatLng.fromLocation(location)
|
||||
this.location = location
|
||||
if (previousLocation == null) {
|
||||
loadChargers()
|
||||
return
|
||||
|
||||
val map = map ?: return
|
||||
if (myLocationEnabled) {
|
||||
if (oldLoc == null) {
|
||||
mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.newLatLngZoom(latLng, 13f))
|
||||
} else if (latLng != oldLoc && distanceBetween(
|
||||
latLng.latitude,
|
||||
latLng.longitude,
|
||||
oldLoc.latitude,
|
||||
oldLoc.longitude
|
||||
) > 1
|
||||
) {
|
||||
// only update map if location changed by more than 1 meter
|
||||
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
|
||||
mapSurfaceCallback.animateCamera(camUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
@@ -413,31 +400,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// if chargers are searched around current location, consider app-driven refresh
|
||||
val searchLocation =
|
||||
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
|
||||
val distance = searchLocation?.let {
|
||||
distanceBetween(
|
||||
it.latitude, it.longitude, location.latitude, location.longitude
|
||||
)
|
||||
} ?: 0.0
|
||||
if (supportsRefresh && (lastChargersUpdateTime == null ||
|
||||
Duration.between(
|
||||
lastChargersUpdateTime,
|
||||
now
|
||||
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
|
||||
) {
|
||||
onContentRefreshRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChargers() {
|
||||
val location = location ?: return
|
||||
|
||||
val searchLocation =
|
||||
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
|
||||
this.searchLocation = searchLocation
|
||||
val map = map ?: return
|
||||
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
loadingError = false
|
||||
@@ -448,56 +415,33 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
val filters = repo.getFiltersAsync(carContext.stringProvider())
|
||||
filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
val apiId = repo.api.value!!.id
|
||||
|
||||
// load chargers
|
||||
if (filterStatus == FILTERS_FAVORITES) {
|
||||
val chargers =
|
||||
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
val chargers = favorites.await().map { it.charger }.sortedBy {
|
||||
distanceBetween(
|
||||
location.latitude, location.longitude,
|
||||
it.coordinates.lat, it.coordinates.lng
|
||||
)
|
||||
}
|
||||
this@MapScreen.chargers = chargers
|
||||
isFavorite = List(chargers.size) { true }
|
||||
} else {
|
||||
// try multiple search radii until we have enough chargers
|
||||
var chargers: List<ChargeLocation>? = null
|
||||
val radiusValues = listOf(searchRadius, searchRadius * 10, searchRadius * 50)
|
||||
for (radius in radiusValues) {
|
||||
val response = repo.getChargepointsRadius(
|
||||
searchLocation,
|
||||
radius,
|
||||
zoom = 16f,
|
||||
filtersWithValue
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR && if (radius == radiusValues.last()) response.data.isNullOrEmpty() else response.data == null) {
|
||||
loadingError = true
|
||||
this@MapScreen.chargers = null
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
if (prefs.placeSearchResultAndroidAutoName == null) {
|
||||
chargers = headingFilter(
|
||||
chargers,
|
||||
searchLocation
|
||||
)
|
||||
}
|
||||
if (chargers == null || chargers.size >= maxRows) {
|
||||
break
|
||||
}
|
||||
val response = repo.getChargepoints(
|
||||
map.projection.visibleRegion.latLngBounds,
|
||||
map.cameraPosition.zoom,
|
||||
filtersWithValue,
|
||||
false
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR || response.data == null) {
|
||||
loadingError = true
|
||||
this@MapScreen.chargers = null
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
val isFavorite = chargers?.map {
|
||||
db.favoritesDao().findFavorite(it.id, apiId) != null
|
||||
}
|
||||
this@MapScreen.chargers = chargers
|
||||
this@MapScreen.isFavorite = isFavorite
|
||||
this@MapScreen.chargers = response.data
|
||||
markerManager?.chargepoints = response.data
|
||||
}
|
||||
|
||||
updateCoroutine = null
|
||||
lastChargersUpdateTime = Instant.now()
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
@@ -510,33 +454,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by heading if heading available and enabled
|
||||
*/
|
||||
private fun headingFilter(
|
||||
chargers: List<ChargeLocation>?,
|
||||
searchLocation: LatLng
|
||||
): List<ChargeLocation>? {
|
||||
// use compass heading if available, otherwise fall back to GPS
|
||||
val location = location
|
||||
val heading = heading?.orientations?.value?.get(0)
|
||||
?: if (location?.hasBearing() == true) location.bearing else null
|
||||
return heading?.let {
|
||||
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
|
||||
|
||||
chargers?.filter {
|
||||
val bearing = bearingBetween(
|
||||
searchLocation.latitude,
|
||||
searchLocation.longitude,
|
||||
it.coordinates.lat,
|
||||
it.coordinates.lng
|
||||
)
|
||||
val diff = headingDiff(bearing, heading.toDouble())
|
||||
abs(diff) < 30
|
||||
}
|
||||
} ?: chargers
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
this.energyLevel = energyLevel
|
||||
@@ -548,15 +465,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
mapSurfaceCallback.getMapAsync(this)
|
||||
setupListeners()
|
||||
session.requestLocationUpdates()
|
||||
locationError = false
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (location == null) {
|
||||
locationError = true
|
||||
invalidate()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Reloading chargers in onStart does not seem to count towards content limit.
|
||||
// So let's do this so the user gets fresh chargers when re-entering the app.
|
||||
@@ -564,7 +475,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
repo.api.value = createApi(prefs.dataSource, carContext)
|
||||
}
|
||||
invalidate()
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
@@ -598,9 +508,20 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
chargers = null
|
||||
availabilities.clear()
|
||||
location = null
|
||||
myLocationEnabled = false
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
super.onPause(owner)
|
||||
|
||||
map?.let {
|
||||
prefs.currentMapLocation = it.cameraPosition.target
|
||||
prefs.currentMapZoom = it.cameraPosition.zoom
|
||||
}
|
||||
prefs.currentMapMyLocationEnabled = myLocationEnabled
|
||||
}
|
||||
|
||||
private fun removeListeners() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
println("Removing energy level listener")
|
||||
@@ -609,17 +530,6 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentRefreshRequested() {
|
||||
loadChargers()
|
||||
availabilities.clear()
|
||||
|
||||
val start = visibleStart
|
||||
val end = visibleEnd
|
||||
if (start != null && end != null) {
|
||||
onItemVisibilityChanged(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
|
||||
// when the list is scrolled, load corresponding availabilities
|
||||
if (startIndex == visibleStart && endIndex == visibleEnd && availabilities.isNotEmpty()) return
|
||||
@@ -641,7 +551,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
availabilityUpdateCoroutine = lifecycleScope.launch {
|
||||
delay(300L)
|
||||
|
||||
val chargers = chargers ?: return@launch
|
||||
val chargers = chargers?.filterIsInstance(ChargeLocation::class.java) ?: return@launch
|
||||
if (chargers.isEmpty()) return@launch
|
||||
|
||||
val tasks = chargers.subList(
|
||||
@@ -664,4 +574,97 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
availabilityUpdateCoroutine = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMapReady(map: AnyMap) {
|
||||
this.map = map
|
||||
this.markerManager =
|
||||
MarkerManager(
|
||||
mapSurfaceCallback.presentation.context,
|
||||
map,
|
||||
this,
|
||||
markerHeight = if (BuildConfig.FLAVOR_automotive == "automotive") 36 else 64
|
||||
).apply {
|
||||
this@MapScreen.chargers?.let { chargepoints = it }
|
||||
onChargerClick = this@MapScreen::onChargerClick
|
||||
onClusterClick = {
|
||||
val newZoom = map.cameraPosition.zoom + 2
|
||||
mapSurfaceCallback.animateCamera(
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(it.coordinates.lat, it.coordinates.lng),
|
||||
newZoom
|
||||
)
|
||||
)
|
||||
}
|
||||
searchResult = prefs.placeSearchResultAndroidAuto
|
||||
highlighedCharger = selectedCharger
|
||||
}
|
||||
|
||||
map.setMyLocationEnabled(true)
|
||||
map.uiSettings.setMyLocationButtonEnabled(false)
|
||||
map.setAttributionClickListener { attributions ->
|
||||
screenManager.push(MapAttributionScreen(carContext, attributions))
|
||||
}
|
||||
map.setOnMapClickListener {
|
||||
clearSelectedCharger()
|
||||
}
|
||||
|
||||
val mode = carContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
map.setMapStyle(
|
||||
if (mode == Configuration.UI_MODE_NIGHT_YES) AnyMap.Style.DARK else AnyMap.Style.NORMAL
|
||||
)
|
||||
|
||||
prefs.placeSearchResultAndroidAuto?.let { place ->
|
||||
// move to the location of the search result
|
||||
myLocationEnabled = false
|
||||
markerManager?.searchResult = place
|
||||
if (place.viewport != null) {
|
||||
map.moveCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
|
||||
} else {
|
||||
map.moveCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
|
||||
}
|
||||
} ?: if (prefs.currentMapMyLocationEnabled) {
|
||||
enableLocation(false)
|
||||
} else {
|
||||
// use position saved in preferences, fall back to default (Europe)
|
||||
val cameraUpdate =
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
prefs.currentMapLocation,
|
||||
prefs.currentMapZoom
|
||||
)
|
||||
map.moveCamera(cameraUpdate)
|
||||
}
|
||||
|
||||
mapSurfaceCallback.cameraMoveStartedListener = {
|
||||
if (myLocationEnabled) {
|
||||
myLocationEnabled = false
|
||||
myLocationNeedsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
mapSurfaceCallback.cameraIdleListener = {
|
||||
loadChargers()
|
||||
if (myLocationNeedsUpdate) {
|
||||
invalidate()
|
||||
myLocationNeedsUpdate = false
|
||||
}
|
||||
}
|
||||
loadChargers()
|
||||
}
|
||||
|
||||
private fun enableLocation(animated: Boolean) {
|
||||
myLocationEnabled = true
|
||||
myLocationNeedsUpdate = true
|
||||
if (location != null) {
|
||||
val map = map ?: return
|
||||
val update = map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng.fromLocation(location),
|
||||
13f
|
||||
)
|
||||
if (animated) {
|
||||
mapSurfaceCallback.animateCamera(update)
|
||||
} else {
|
||||
map.moveCamera(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
284
app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
Normal file
284
app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
Normal file
@@ -0,0 +1,284 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Presentation
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.SurfaceCallback
|
||||
import androidx.car.app.SurfaceContainer
|
||||
import androidx.car.app.annotations.RequiresCarApi
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.AnyMap.CancelableCallback
|
||||
import com.car2go.maps.CameraUpdate
|
||||
import com.car2go.maps.MapContainerView
|
||||
import com.car2go.maps.MapFactory
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
class MapSurfaceCallback(val ctx: CarContext, val lifecycleScope: LifecycleCoroutineScope) :
|
||||
SurfaceCallback, OnMapReadyCallback {
|
||||
private val VIRTUAL_DISPLAY_NAME = "evmap_map"
|
||||
private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000
|
||||
private val STATUSBAR_OFFSET_SYSTEMS = listOf(
|
||||
"VolvoCars/ihu_emulator_volvo_car/ihu_emulator:11",
|
||||
"Google/sdk_gcar_x86_64/generic_64bitonly_x86_64:11"
|
||||
)
|
||||
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
|
||||
private lateinit var virtualDisplay: VirtualDisplay
|
||||
lateinit var presentation: Presentation
|
||||
private lateinit var mapView: MapContainerView
|
||||
private var width: Int = 0
|
||||
private var height: Int = 0
|
||||
private var visibleArea: Rect? = null
|
||||
private var map: AnyMap? = null
|
||||
private val mapCallbacks = mutableListOf<OnMapReadyCallback>()
|
||||
|
||||
private var flingAnimator: ValueAnimator? = null
|
||||
private var idle = true
|
||||
private var idleDelay: Job? = null
|
||||
var cameraMoveStartedListener: (() -> Unit)? = null
|
||||
var cameraIdleListener: (() -> Unit)? = null
|
||||
|
||||
|
||||
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
|
||||
if (surfaceContainer.surface == null || surfaceContainer.dpi == 0 || surfaceContainer.height == 0 || surfaceContainer.width == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.FINGERPRINT.contains("emulator") || Build.FINGERPRINT.contains("sdk_gcar")) {
|
||||
// fix for MapLibre in Android Automotive Emulators
|
||||
System.setProperty("ro.kernel.qemu", "1")
|
||||
}
|
||||
|
||||
width = surfaceContainer.width
|
||||
height = surfaceContainer.height
|
||||
virtualDisplay = ContextCompat
|
||||
.getSystemService(ctx, DisplayManager::class.java)!!
|
||||
.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
width,
|
||||
height,
|
||||
(surfaceContainer.dpi * when (getMapProvider()) {
|
||||
"mapbox" -> 1.6
|
||||
"google" -> 1.0
|
||||
else -> 1.0
|
||||
}).roundToInt(),
|
||||
surfaceContainer.surface,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
|
||||
)
|
||||
presentation = Presentation(ctx, virtualDisplay.display, R.style.AppTheme)
|
||||
|
||||
mapView = createMap(presentation.context)
|
||||
mapView.onCreate(null)
|
||||
mapView.onResume()
|
||||
|
||||
presentation.setContentView(mapView)
|
||||
presentation.show()
|
||||
|
||||
mapView.getMapAsync(this)
|
||||
}
|
||||
|
||||
private fun getMapProvider(): String = if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
|
||||
"mapbox"
|
||||
} else prefs.mapProvider
|
||||
|
||||
override fun onVisibleAreaChanged(visibleArea: Rect) {
|
||||
Log.d("MapSurfaceCallback", "visible area: $visibleArea")
|
||||
this.visibleArea = visibleArea
|
||||
updateVisibleArea()
|
||||
}
|
||||
|
||||
override fun onStableAreaChanged(stableArea: Rect) {
|
||||
Log.d("MapSurfaceCallback", "stable area: $stableArea")
|
||||
}
|
||||
|
||||
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
|
||||
mapView.onPause()
|
||||
mapView.onStop()
|
||||
mapView.onDestroy()
|
||||
map = null
|
||||
|
||||
presentation.dismiss()
|
||||
virtualDisplay.release()
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onScroll(distanceX: Float, distanceY: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val map = map ?: return
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(distanceX, distanceY))
|
||||
dispatchCameraMoveStarted()
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onFling(velocityX: Float, velocityY: Float) {
|
||||
val map = map ?: return
|
||||
val screenDensity: Float = presentation.resources.displayMetrics.density
|
||||
|
||||
// calculate velocity vector for xy dimensions, independent from screen size
|
||||
val velocityXY =
|
||||
hypot((velocityX / screenDensity).toDouble(), (velocityY / screenDensity).toDouble())
|
||||
if (velocityXY < VELOCITY_THRESHOLD_IGNORE_FLING) {
|
||||
// ignore short flings, these can occur when other gestures just have finished executing
|
||||
return
|
||||
}
|
||||
|
||||
idleDelay?.cancel()
|
||||
|
||||
val offsetX = velocityX / 10
|
||||
val offsetY = velocityY / 10
|
||||
val animationTime = (velocityXY / 10).roundToLong()
|
||||
|
||||
flingAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = animationTime
|
||||
interpolator = LinearOutSlowInInterpolator()
|
||||
|
||||
var last = 0f
|
||||
addUpdateListener {
|
||||
val current = it.animatedFraction
|
||||
val diff = last - current
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(diff * offsetX, diff * offsetY))
|
||||
last = current
|
||||
}
|
||||
start()
|
||||
|
||||
doOnEnd { dispatchCameraIdle() }
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresCarApi(2)
|
||||
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val map = map ?: return
|
||||
if (scaleFactor == 2f) return
|
||||
|
||||
val offsetX = (focusX - mapView.width / 2) * (scaleFactor - 1f)
|
||||
val offsetY = (offsetY(focusY) - mapView.height / 2) * (scaleFactor - 1f)
|
||||
|
||||
Log.i("MapSurfaceCallback", "focus: $focusX, $focusY, scaleFactor: $scaleFactor")
|
||||
map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1))
|
||||
map.moveCamera(map.cameraUpdateFactory.scrollBy(offsetX, offsetY))
|
||||
dispatchCameraMoveStarted()
|
||||
}
|
||||
|
||||
fun animateCamera(update: CameraUpdate) {
|
||||
val map = map ?: return
|
||||
map.animateCamera(update, object : CancelableCallback {
|
||||
override fun onFinish() {
|
||||
dispatchCameraIdle()
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun dispatchCameraMoveStarted() {
|
||||
if (idle) {
|
||||
idle = false
|
||||
cameraMoveStartedListener?.invoke()
|
||||
}
|
||||
idleDelay?.cancel()
|
||||
idleDelay = lifecycleScope.launch {
|
||||
delay(500)
|
||||
dispatchCameraIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchCameraIdle() {
|
||||
idle = true
|
||||
cameraIdleListener?.invoke()
|
||||
}
|
||||
|
||||
@RequiresCarApi(5)
|
||||
override fun onClick(x: Float, y: Float) {
|
||||
flingAnimator?.cancel()
|
||||
val downTime: Long = SystemClock.uptimeMillis()
|
||||
val eventTime: Long = downTime + 100
|
||||
val yOffset = offsetY(y)
|
||||
|
||||
val downEvent = MotionEvent.obtain(
|
||||
downTime,
|
||||
downTime,
|
||||
MotionEvent.ACTION_DOWN,
|
||||
x,
|
||||
yOffset,
|
||||
0
|
||||
)
|
||||
mapView.dispatchTouchEvent(downEvent)
|
||||
downEvent.recycle()
|
||||
val upEvent = MotionEvent.obtain(
|
||||
downTime,
|
||||
eventTime,
|
||||
MotionEvent.ACTION_UP,
|
||||
x,
|
||||
yOffset,
|
||||
0
|
||||
)
|
||||
mapView.dispatchTouchEvent(upEvent)
|
||||
upEvent.recycle()
|
||||
}
|
||||
|
||||
private fun offsetY(y: Float): Float {
|
||||
if (!STATUSBAR_OFFSET_SYSTEMS.any { Build.FINGERPRINT.startsWith(it) }) return y
|
||||
|
||||
// In some emulators, touch locations are offset by the status bar height
|
||||
// related: https://issuetracker.google.com/issues/256905247
|
||||
val resId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
val offset = resId.takeIf { it > 0 }?.let { ctx.resources.getDimensionPixelSize(it) } ?: 0
|
||||
return y + offset
|
||||
}
|
||||
|
||||
private fun createMap(ctx: Context): MapContainerView {
|
||||
val priority = arrayOf(
|
||||
when (getMapProvider()) {
|
||||
"mapbox" -> MapFactory.MAPLIBRE
|
||||
"google" -> MapFactory.GOOGLE
|
||||
else -> null
|
||||
},
|
||||
MapFactory.GOOGLE,
|
||||
MapFactory.MAPLIBRE
|
||||
)
|
||||
return MapFactory.createMap(ctx, priority).view
|
||||
}
|
||||
|
||||
override fun onMapReady(anyMap: AnyMap) {
|
||||
this.map = anyMap
|
||||
updateVisibleArea()
|
||||
mapCallbacks.forEach { it.onMapReady(anyMap) }
|
||||
mapCallbacks.clear()
|
||||
}
|
||||
|
||||
private fun updateVisibleArea() {
|
||||
visibleArea?.let {
|
||||
map?.setPadding(it.left, it.top, width - it.right, height - it.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMapAsync(callback: OnMapReadyCallback) {
|
||||
mapCallbacks.add(callback)
|
||||
}
|
||||
}
|
||||
@@ -11,19 +11,34 @@ import androidx.car.app.annotations.ExperimentalCarApi
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.DistanceSpan
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SearchTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.iconForPlaceType
|
||||
import net.vonforst.evmap.adapter.isSpecialPlace
|
||||
import net.vonforst.evmap.autocomplete.*
|
||||
import net.vonforst.evmap.autocomplete.ApiUnavailableException
|
||||
import net.vonforst.evmap.autocomplete.AutocompletePlace
|
||||
import net.vonforst.evmap.autocomplete.AutocompleteProvider
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.autocomplete.getAutocompleteProviders
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.storage.RecentAutocompletePlace
|
||||
@@ -117,7 +132,7 @@ class PlaceSearchScreen(
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val placeDetails = getDetails(place.id) ?: return@launch
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails
|
||||
prefs.placeSearchResultAndroidAutoName =
|
||||
place.primaryText.toString()
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
|
||||
@@ -114,22 +114,25 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
}
|
||||
.build()
|
||||
)
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.showChargersAheadAndroidAuto = it
|
||||
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
if (carContext.carAppApiLevel < 7 || !carContext.isAppDrivenRefreshSupported) {
|
||||
// this option is only supported in LegacyMapScreen
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.showChargersAheadAndroidAuto = it
|
||||
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
addItem(
|
||||
Row.Builder()
|
||||
@@ -164,6 +167,10 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_names)
|
||||
val searchProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
val mapProviderNames =
|
||||
carContext.resources.getStringArray(R.array.pref_map_provider_names)
|
||||
val mapProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_map_provider_values)
|
||||
|
||||
var teslaLoggingIn = false
|
||||
|
||||
@@ -203,6 +210,25 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
if (supportsNewMapScreen(carContext) && BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// Google Maps SDK is not available on AAOS (not even AAOS with GAS, so far)
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_map_provider))
|
||||
setBrowsable(true)
|
||||
val mapProviderId = prefs.mapProvider
|
||||
val mapProviderDesc =
|
||||
mapProviderNames[mapProviderValues.indexOf(mapProviderId)]
|
||||
addText(mapProviderDesc)
|
||||
setOnClickListener {
|
||||
screenManager.push(
|
||||
ChooseDataSourceScreen(
|
||||
carContext,
|
||||
ChooseDataSourceScreen.Type.MAP_PROVIDER
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_search_delete_recent))
|
||||
setOnClickListener {
|
||||
@@ -341,25 +367,33 @@ class ChooseDataSourceScreen(
|
||||
@StringRes val extraDesc: Int? = null
|
||||
) : Screen(ctx) {
|
||||
enum class Type {
|
||||
CHARGER_DATA_SOURCE, SEARCH_PROVIDER
|
||||
CHARGER_DATA_SOURCE, SEARCH_PROVIDER, MAP_PROVIDER
|
||||
}
|
||||
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
val title = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source
|
||||
Type.SEARCH_PROVIDER -> R.string.pref_search_provider
|
||||
Type.MAP_PROVIDER -> R.string.pref_map_provider
|
||||
}
|
||||
val names = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names)
|
||||
}
|
||||
val values = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values)
|
||||
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
}
|
||||
val names = carContext.resources.getStringArray(
|
||||
when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_names
|
||||
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_names
|
||||
Type.MAP_PROVIDER -> R.array.pref_map_provider_names
|
||||
}
|
||||
)
|
||||
val values = carContext.resources.getStringArray(
|
||||
when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> R.array.pref_data_source_values
|
||||
Type.SEARCH_PROVIDER -> R.array.pref_search_provider_values
|
||||
Type.MAP_PROVIDER -> R.array.pref_map_provider_values
|
||||
}
|
||||
)
|
||||
val currentValue: String = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> prefs.dataSource
|
||||
Type.SEARCH_PROVIDER -> prefs.searchProvider
|
||||
Type.MAP_PROVIDER -> prefs.mapProvider
|
||||
}
|
||||
val descriptions = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> listOf(
|
||||
@@ -367,6 +401,7 @@ class ChooseDataSourceScreen(
|
||||
carContext.getString(R.string.data_source_openchargemap_desc)
|
||||
)
|
||||
Type.SEARCH_PROVIDER -> null
|
||||
Type.MAP_PROVIDER -> null
|
||||
}
|
||||
val callback: (String) -> Unit = when (type) {
|
||||
Type.CHARGER_DATA_SOURCE -> { it ->
|
||||
@@ -376,6 +411,9 @@ class ChooseDataSourceScreen(
|
||||
Type.SEARCH_PROVIDER -> { it ->
|
||||
prefs.searchProvider = it
|
||||
}
|
||||
Type.MAP_PROVIDER -> { it ->
|
||||
prefs.mapProvider = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
|
||||
@@ -4,19 +4,26 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.util.Log
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.HostException
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarIconSpan
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import androidx.car.app.model.MessageTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
@@ -24,11 +31,17 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.ftPerMile
|
||||
import net.vonforst.evmap.getPackageInfoCompat
|
||||
import net.vonforst.evmap.kmPerMile
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.shouldUseImperialUnits
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ydPerMile
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
@@ -221,6 +234,9 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun supportsNewMapScreen(ctx: CarContext) =
|
||||
ctx.carAppApiLevel >= 7 && ctx.isAppDrivenRefreshSupported
|
||||
|
||||
fun openUrl(carContext: CarContext, url: String) {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
@@ -255,6 +271,54 @@ fun openUrl(carContext: CarContext, url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToCharger(ctx: CarContext, charger: ChargeLocation) {
|
||||
val success = navigateCarApp(ctx, charger)
|
||||
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// on AAOS, some OEMs' navigation apps might not support
|
||||
navigateRegularApp(ctx, charger)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateCarApp(ctx: CarContext, charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
Intent(
|
||||
CarContext.ACTION_NAVIGATE,
|
||||
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||
)
|
||||
try {
|
||||
ctx.startCarApp(intent)
|
||||
return true
|
||||
} catch (e: HostException) {
|
||||
Log.w("navigateToCharger", "Could not start navigation using car app intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
e.printStackTrace()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("navigateToCharger", "Could not start navigation using car app intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun navigateRegularApp(ctx: CarContext, charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(
|
||||
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
|
||||
Uri.encode(charger.name)
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(ctx.packageManager) != null) {
|
||||
ctx.startActivity(intent)
|
||||
return true
|
||||
} else {
|
||||
Log.w("navigateToCharger", "Could not start navigation using regular intent")
|
||||
Log.w("navigateToCharger", intent.toString())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
|
||||
/*
|
||||
Dummy screen to get around template refresh limitations.
|
||||
@@ -279,4 +343,49 @@ class TextMeasurer(ctx: CarContext) {
|
||||
fun measureText(text: CharSequence): Float {
|
||||
return textPaint.measureText(text, 0, text.length)
|
||||
}
|
||||
}
|
||||
|
||||
fun generateChargepointsText(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
ctx: Context
|
||||
): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
chargepointsText.apply {
|
||||
if (i > 0) append(" · ")
|
||||
append("${cp.count}× ")
|
||||
val plugIcon = iconForPlugType(cp.type)
|
||||
if (plugIcon != 0) {
|
||||
append(
|
||||
nameForPlugType(ctx.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
ctx,
|
||||
plugIcon
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
} else {
|
||||
append(nameForPlugType(ctx.stringProvider(), cp.type))
|
||||
}
|
||||
cp.formatPower()?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return chargepointsText
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.mapbox.api.geocoding.v5.models.CarmenFeature
|
||||
import com.mapbox.geojson.BoundingBox
|
||||
import com.mapbox.geojson.Point
|
||||
import net.vonforst.evmap.R
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
@@ -25,7 +26,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
override val id = "mapbox"
|
||||
|
||||
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
|
||||
val result = MapboxGeocoding.builder().apply {
|
||||
val request = MapboxGeocoding.builder().apply {
|
||||
location?.let {
|
||||
proximity(Point.fromLngLat(location.longitude, location.latitude))
|
||||
}
|
||||
@@ -33,7 +34,12 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
accessToken(context.getString(R.string.mapbox_key))
|
||||
autocomplete(true)
|
||||
this.query(query)
|
||||
}.build().executeCall()
|
||||
}
|
||||
val result = try {
|
||||
request.build().executeCall()
|
||||
} catch (e: HttpException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
if (!result.isSuccessful) {
|
||||
throw IOException(result.message())
|
||||
}
|
||||
|
||||
@@ -55,12 +55,10 @@ import androidx.transition.TransitionManager
|
||||
import coil.load
|
||||
import coil.memory.MemoryCache
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.MapFactory
|
||||
import com.car2go.maps.MapFragment
|
||||
import com.car2go.maps.OnMapReadyCallback
|
||||
import com.car2go.maps.model.BitmapDescriptor
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.Marker
|
||||
import com.car2go.maps.model.MarkerOptions
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
@@ -79,10 +77,7 @@ import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.michaelrocks.bimap.HashBiMap
|
||||
import io.michaelrocks.bimap.MutableBiMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.ConnectorAdapter
|
||||
@@ -98,7 +93,6 @@ import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.model.ChargerPhoto
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
@@ -107,14 +101,8 @@ import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.shouldUseImperialUnits
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.ClusterIconGenerator
|
||||
import net.vonforst.evmap.ui.HideOnScrollFabBehavior
|
||||
import net.vonforst.evmap.ui.MarkerAnimator
|
||||
import net.vonforst.evmap.ui.chargerZ
|
||||
import net.vonforst.evmap.ui.clusterZ
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.ui.placeSearchZ
|
||||
import net.vonforst.evmap.ui.MarkerManager
|
||||
import net.vonforst.evmap.ui.setTouchModal
|
||||
import net.vonforst.evmap.utils.boundingBox
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
@@ -129,9 +117,6 @@ import net.vonforst.evmap.viewmodel.Status
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -142,24 +127,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
private val galleryVm: GalleryViewModel by activityViewModels()
|
||||
private var mapFragment: MapFragment? = null
|
||||
private var map: AnyMap? = null
|
||||
private var markerManager: MarkerManager? = null
|
||||
private lateinit var locationEngine: LocationEngine
|
||||
private var requestingLocationUpdates = false
|
||||
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
|
||||
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
|
||||
private lateinit var detailsDialog: ConnectorDetailsDialog
|
||||
private lateinit var prefs: PreferenceDataSource
|
||||
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
||||
private var clusterMarkers: List<Marker> = emptyList()
|
||||
private var searchResultMarker: Marker? = null
|
||||
private var searchResultIcon: BitmapDescriptor? = null
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
private var previousChargepointIds: Set<Long>? = null
|
||||
private var mapTopPadding: Int = 0
|
||||
private var popupMenu: PopupMenu? = null
|
||||
|
||||
private lateinit var clusterIconGenerator: ClusterIconGenerator
|
||||
private lateinit var chargerIconGenerator: ChargerIconGenerator
|
||||
private lateinit var animator: MarkerAnimator
|
||||
private lateinit var favToggle: MenuItem
|
||||
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
@@ -198,7 +175,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
prefs = PreferenceDataSource(requireContext())
|
||||
|
||||
locationEngine = FusionEngine(requireContext())
|
||||
clusterIconGenerator = ClusterIconGenerator(requireContext())
|
||||
|
||||
enterTransition = MaterialFadeThrough()
|
||||
exitTransition = MaterialFadeThrough()
|
||||
@@ -225,24 +201,20 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
mapFragment = MapFragment()
|
||||
mapFragment!!.priority = arrayOf(
|
||||
when (provider) {
|
||||
"mapbox" -> MapFragment.MAPLIBRE
|
||||
"google" -> MapFragment.GOOGLE
|
||||
"mapbox" -> MapFactory.MAPLIBRE
|
||||
"google" -> MapFactory.GOOGLE
|
||||
else -> null
|
||||
},
|
||||
MapFragment.GOOGLE,
|
||||
MapFragment.MAPLIBRE
|
||||
MapFactory.GOOGLE,
|
||||
MapFactory.MAPLIBRE
|
||||
)
|
||||
childFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.map, mapFragment!!, mapFragmentTag)
|
||||
.commit()
|
||||
|
||||
// reset map-related stuff (map provider may have changed)
|
||||
map = null
|
||||
markers.clear()
|
||||
clusterMarkers = emptyList()
|
||||
searchResultMarker = null
|
||||
searchResultIcon = null
|
||||
markerManager = null
|
||||
}
|
||||
|
||||
binding.detailAppBar.toolbar.popupTheme =
|
||||
@@ -297,6 +269,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet || !prefs.privacyAccepted) {
|
||||
findNavController().navigate(R.id.onboarding)
|
||||
}
|
||||
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
mapFragment!!.getMapAsync(this)
|
||||
@@ -628,16 +604,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
} else {
|
||||
vm.insertFavorite(charger)
|
||||
}
|
||||
markers.inverse[charger]?.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = fav == null,
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupObservers() {
|
||||
@@ -689,16 +655,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.fabDirections.show()
|
||||
detailAppBarBehavior.setToolbarTitle(it.name)
|
||||
updateFavoriteToggle()
|
||||
highlightMarker(it)
|
||||
markerManager?.highlighedCharger = it
|
||||
markerManager?.animateBounce(it)
|
||||
} else {
|
||||
bottomSheetBehavior.state = STATE_HIDDEN
|
||||
unhighlightAllMarkers()
|
||||
markerManager?.highlighedCharger = null
|
||||
}
|
||||
}
|
||||
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
|
||||
val chargepoints = res.data
|
||||
if (chargepoints != null) {
|
||||
updateMap(chargepoints)
|
||||
markerManager?.chargepoints = chargepoints
|
||||
}
|
||||
when (res.status) {
|
||||
Status.ERROR -> {
|
||||
@@ -721,10 +688,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
})
|
||||
vm.useMiniMarkers.observe(viewLifecycleOwner) {
|
||||
vm.chargepoints.value?.data?.let { updateMap(it) }
|
||||
markerManager?.mini = it
|
||||
}
|
||||
vm.filteredConnectors.observe(viewLifecycleOwner) {
|
||||
markerManager?.filteredConnectors = it
|
||||
}
|
||||
vm.favorites.observe(viewLifecycleOwner) {
|
||||
updateFavoriteToggle()
|
||||
markerManager?.favorites = it.map { it.favorite.chargerId }.toSet()
|
||||
}
|
||||
vm.searchResult.observe(viewLifecycleOwner) { place ->
|
||||
displaySearchResult(place, moveCamera = true)
|
||||
@@ -755,8 +726,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
|
||||
val map = this.map ?: return
|
||||
searchResultMarker?.remove()
|
||||
searchResultMarker = null
|
||||
markerManager?.searchResult = place
|
||||
|
||||
if (place != null) {
|
||||
// disable location following when search result is shown
|
||||
@@ -768,18 +738,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
|
||||
}
|
||||
}
|
||||
|
||||
if (searchResultIcon == null) {
|
||||
searchResultIcon =
|
||||
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
|
||||
}
|
||||
searchResultMarker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.z(placeSearchZ)
|
||||
.position(place.latLng)
|
||||
.icon(searchResultIcon)
|
||||
.anchor(0.5f, 1f)
|
||||
)
|
||||
} else {
|
||||
binding.search.setText("")
|
||||
}
|
||||
@@ -796,53 +754,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|| vm.selectedChargepoint.value != null
|
||||
}
|
||||
|
||||
private fun unhighlightAllMarkers() {
|
||||
markers.forEach { (m, c) ->
|
||||
m.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun highlightMarker(charger: ChargeLocation) {
|
||||
val marker = markers.inverse[charger] ?: return
|
||||
// highlight this marker
|
||||
marker.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = true,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
|
||||
|
||||
// un-highlight all other markers
|
||||
markers.forEach { (m, c) ->
|
||||
if (m != marker) {
|
||||
m.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(c, vm.filteredConnectors.value),
|
||||
highlight = false,
|
||||
fault = c.faultReport != null,
|
||||
multi = c.isMulti(vm.filteredConnectors.value),
|
||||
fav = c.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFavoriteToggle() {
|
||||
val favs = vm.favorites.value ?: return
|
||||
val charger = vm.chargerSparse.value ?: return
|
||||
@@ -1051,26 +962,26 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
this.map = map
|
||||
vm.mapProjection = map.projection
|
||||
val context = this.context ?: return
|
||||
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
|
||||
|
||||
vm.mapTrafficSupported.value =
|
||||
mapFragment?.let { AnyMap.Feature.TRAFFIC_LAYER in it.supportedFeatures } ?: false
|
||||
|
||||
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
|
||||
// Google Maps: icons can be generated in background thread
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
view ?: return
|
||||
markerManager = MarkerManager(context, map, this).apply {
|
||||
onChargerClick = {
|
||||
vm.chargerSparse.value = it
|
||||
}
|
||||
} else {
|
||||
// MapLibre: needs to be run on main thread
|
||||
chargerIconGenerator.preloadCache()
|
||||
onClusterClick = {
|
||||
val newZoom = map.cameraPosition.zoom + 2
|
||||
map.animateCamera(
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
LatLng(it.coordinates.lat, it.coordinates.lng),
|
||||
newZoom
|
||||
)
|
||||
)
|
||||
}
|
||||
chargepoints = vm.chargepoints.value?.data ?: emptyList()
|
||||
highlighedCharger = vm.chargerSparse.value
|
||||
searchResult = vm.searchResult.value
|
||||
favorites = vm.favorites.value?.map { it.favorite.chargerId }?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
|
||||
|
||||
animator = MarkerAnimator(chargerIconGenerator)
|
||||
map.uiSettings.setTiltGesturesEnabled(false)
|
||||
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
|
||||
map.setIndoorEnabled(false)
|
||||
@@ -1123,26 +1034,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
}
|
||||
map.setOnMarkerClickListener { marker ->
|
||||
when (marker) {
|
||||
in markers -> {
|
||||
vm.chargerSparse.value = markers[marker]
|
||||
true
|
||||
}
|
||||
in clusterMarkers -> {
|
||||
val newZoom = map.cameraPosition.zoom + 2
|
||||
map.animateCamera(
|
||||
map.cameraUpdateFactory.newLatLngZoom(
|
||||
marker.position,
|
||||
newZoom
|
||||
)
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
}
|
||||
map.setOnMapClickListener {
|
||||
if (backPressedCallback.isEnabled) {
|
||||
backPressedCallback.handleOnBackPressed()
|
||||
@@ -1274,111 +1165,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun updateMap(chargepoints: List<ChargepointListItem>) {
|
||||
val map = this.map ?: return
|
||||
clusterMarkers.forEach { it.remove() }
|
||||
|
||||
val clusters = chargepoints.filterIsInstance<ChargeLocationCluster>()
|
||||
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
|
||||
|
||||
val chargepointIds = chargers.map { it.id }.toSet()
|
||||
|
||||
// update icons of existing markers (connector filter may have changed)
|
||||
for ((marker, charger) in markers) {
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
marker.setIcon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, vm.filteredConnectors.value),
|
||||
highlight = highlight,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(vm.filteredConnectors.value),
|
||||
fav = charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList()),
|
||||
mini = vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
}
|
||||
|
||||
if (chargers.toSet() != markers.values) {
|
||||
// remove markers that disappeared
|
||||
val bounds = map.projection.visibleRegion.latLngBounds
|
||||
markers.entries.toList().forEach {
|
||||
val marker = it.key
|
||||
val charger = it.value
|
||||
if (!chargepointIds.contains(charger.id)) {
|
||||
// animate marker if it is visible, otherwise remove immediately
|
||||
if (bounds.contains(marker.position)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav =
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
animator.animateMarkerDisappear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
}
|
||||
markers.remove(marker)
|
||||
}
|
||||
}
|
||||
// add new markers
|
||||
val map1 = markers.values.map { it.id }
|
||||
for (charger in chargers) {
|
||||
if (!map1.contains(charger.id)) {
|
||||
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
|
||||
val highlight = charger.id == vm.chargerSparse.value?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(vm.filteredConnectors.value)
|
||||
val fav =
|
||||
charger.id in (vm.favorites.value?.map { it.charger.id } ?: emptyList())
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.z(chargerZ)
|
||||
.icon(
|
||||
chargerIconGenerator.getBitmapDescriptor(
|
||||
tint,
|
||||
0f,
|
||||
255,
|
||||
highlight,
|
||||
fault,
|
||||
multi,
|
||||
fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
|
||||
)
|
||||
animator.animateMarkerAppear(
|
||||
marker, tint, highlight, fault, multi, fav,
|
||||
vm.useMiniMarkers.value == true
|
||||
)
|
||||
markers[marker] = charger
|
||||
}
|
||||
}
|
||||
previousChargepointIds = chargepointIds
|
||||
}
|
||||
clusterMarkers = clusters.map { cluster ->
|
||||
map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
|
||||
.z(clusterZ)
|
||||
.icon(
|
||||
map.bitmapDescriptorFactory.fromBitmap(
|
||||
clusterIconGenerator.makeIcon(
|
||||
cluster.clusterCount.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.map, menu)
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.content.SharedPreferences.Editor
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import java.time.Instant
|
||||
@@ -108,11 +110,14 @@ class PreferenceDataSource(val context: Context) {
|
||||
val darkmode: String
|
||||
get() = sp.getString("darkmode", "default")!!
|
||||
|
||||
val mapProvider: String
|
||||
var mapProvider: String
|
||||
get() = sp.getString(
|
||||
"map_provider",
|
||||
context.getString(R.string.pref_map_provider_default)
|
||||
)!!
|
||||
set(value) {
|
||||
sp.edit().putString("map_provider", value).apply()
|
||||
}
|
||||
|
||||
var searchProvider: String
|
||||
get() = sp.getString(
|
||||
@@ -250,10 +255,16 @@ class PreferenceDataSource(val context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
var placeSearchResultAndroidAuto: LatLng?
|
||||
get() = sp.getLatLng("place_search_result_android_auto")
|
||||
var placeSearchResultAndroidAuto: PlaceWithBounds?
|
||||
get() {
|
||||
val latLng = sp.getLatLng("place_search_result_android_auto")
|
||||
val bounds = sp.getLatLngBounds("place_search_result_android_auto_viewport")
|
||||
return latLng?.let { PlaceWithBounds(latLng, bounds) }
|
||||
}
|
||||
set(value) {
|
||||
sp.edit().putLatLng("place_search_result_android_auto", value).apply()
|
||||
sp.edit().putLatLng("place_search_result_android_auto", value?.latLng).apply()
|
||||
sp.edit().putLatLngBounds("place_search_result_android_auto_viewport", value?.viewport)
|
||||
.apply()
|
||||
}
|
||||
|
||||
var placeSearchResultAndroidAutoName: String?
|
||||
@@ -315,7 +326,7 @@ class PreferenceDataSource(val context: Context) {
|
||||
}
|
||||
|
||||
fun SharedPreferences.getLatLng(key: String): LatLng? =
|
||||
if (contains("${key}_lat") && contains("${key}_lng")) {
|
||||
if (containsLatLng(key)) {
|
||||
LatLng(
|
||||
Double.fromBits(getLong("${key}_lat", 0L)),
|
||||
Double.fromBits(getLong("${key}_lng", 0L))
|
||||
@@ -332,3 +343,23 @@ fun Editor.putLatLng(key: String, value: LatLng?): Editor {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedPreferences.containsLatLng(key: String) = contains("${key}_lat") && contains("${key}_lng")
|
||||
|
||||
fun SharedPreferences.getLatLngBounds(key: String): LatLngBounds? =
|
||||
if (containsLatLng("${key}_sw") && containsLatLng("${key}_ne")) {
|
||||
LatLngBounds(
|
||||
getLatLng("${key}_sw"), getLatLng("${key}_ne")
|
||||
)
|
||||
} else null
|
||||
|
||||
fun Editor.putLatLngBounds(key: String, value: LatLngBounds?): Editor {
|
||||
if (value == null) {
|
||||
putLatLng("${key}_sw", null)
|
||||
putLatLng("${key}_ne", null)
|
||||
} else {
|
||||
putLatLng("${key}_sw", value.southwest)
|
||||
putLatLng("${key}_ne", value.northeast)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.view.animation.BounceInterpolator
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.Marker
|
||||
import com.car2go.maps.model.MarkerOptions
|
||||
import io.michaelrocks.bimap.HashBiMap
|
||||
import io.michaelrocks.bimap.MutableBiMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.autocomplete.PlaceWithBounds
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ChargeLocationCluster
|
||||
import net.vonforst.evmap.model.ChargepointListItem
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.max
|
||||
|
||||
fun getMarkerTint(
|
||||
@@ -29,6 +45,217 @@ val chargerZ = 1
|
||||
val clusterZ = chargerZ + 1
|
||||
val placeSearchZ = clusterZ + 1
|
||||
|
||||
class MarkerManager(
|
||||
val context: Context,
|
||||
val map: AnyMap,
|
||||
val lifecycle: LifecycleOwner,
|
||||
markerHeight: Int = 48
|
||||
) {
|
||||
private val clusterIconGenerator = ClusterIconGenerator(context)
|
||||
private val chargerIconGenerator =
|
||||
ChargerIconGenerator(context, map.bitmapDescriptorFactory, height = markerHeight)
|
||||
private val prefs = PreferenceDataSource(context)
|
||||
private val animator = MarkerAnimator(chargerIconGenerator)
|
||||
|
||||
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
|
||||
private var clusterMarkers: MutableBiMap<Marker, ChargeLocationCluster> = HashBiMap()
|
||||
private var searchResultMarker: Marker? = null
|
||||
private var searchResultIcon =
|
||||
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
|
||||
|
||||
var mini = false
|
||||
var filteredConnectors: Set<String>? = null
|
||||
var onChargerClick: ((ChargeLocation) -> Unit)? = null
|
||||
var onClusterClick: ((ChargeLocationCluster) -> Unit)? = null
|
||||
|
||||
var chargepoints: List<ChargepointListItem> = emptyList()
|
||||
@Synchronized set(value) {
|
||||
field = value
|
||||
updateChargepoints()
|
||||
}
|
||||
|
||||
var highlighedCharger: ChargeLocation? = null
|
||||
set(value) {
|
||||
field = value
|
||||
updateChargerIcons()
|
||||
}
|
||||
|
||||
var searchResult: PlaceWithBounds? = null
|
||||
set(value) {
|
||||
field = value
|
||||
updateSearchResultMarker()
|
||||
}
|
||||
|
||||
var favorites: Set<Long> = emptySet()
|
||||
set(value) {
|
||||
field = value
|
||||
updateChargerIcons()
|
||||
}
|
||||
|
||||
init {
|
||||
map.setOnMarkerClickListener { marker ->
|
||||
when (marker) {
|
||||
in markers -> {
|
||||
val charger = markers[marker] ?: return@setOnMarkerClickListener false
|
||||
onChargerClick?.invoke(charger)
|
||||
true
|
||||
}
|
||||
|
||||
in clusterMarkers -> {
|
||||
val cluster = clusterMarkers[marker] ?: return@setOnMarkerClickListener false
|
||||
onClusterClick?.invoke(cluster)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR.contains("google") && prefs.mapProvider == "google") {
|
||||
// Google Maps: icons can be generated in background thread
|
||||
lifecycle.lifecycleScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MapLibre: needs to be run on main thread
|
||||
chargerIconGenerator.preloadCache()
|
||||
}
|
||||
}
|
||||
|
||||
fun animateBounce(charger: ChargeLocation) {
|
||||
val marker = markers.inverse[charger] ?: return
|
||||
animator.animateMarkerBounce(marker, mini)
|
||||
}
|
||||
|
||||
private fun updateSearchResultMarker() {
|
||||
searchResultMarker?.remove()
|
||||
searchResultMarker = null
|
||||
|
||||
searchResult?.let {
|
||||
searchResultMarker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.z(placeSearchZ)
|
||||
.position(it.latLng)
|
||||
.icon(searchResultIcon)
|
||||
.anchor(0.5f, 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateChargepoints() {
|
||||
val clusters = chargepoints.filterIsInstance<ChargeLocationCluster>()
|
||||
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
|
||||
|
||||
val chargepointIds = chargers.map { it.id }.toSet()
|
||||
|
||||
// update icons of existing markers (connector filter may have changed)
|
||||
updateChargerIcons()
|
||||
|
||||
if (chargers.toSet() != markers.values) {
|
||||
// remove markers that disappeared
|
||||
val bounds = map.projection.visibleRegion.latLngBounds
|
||||
markers.entries.toList().forEach { (marker, charger) ->
|
||||
if (!chargepointIds.contains(charger.id)) {
|
||||
// animate marker if it is visible, otherwise remove immediately
|
||||
if (bounds.contains(marker.position)) {
|
||||
animateMarker(charger, marker, false)
|
||||
} else {
|
||||
animator.deleteMarker(marker)
|
||||
}
|
||||
markers.remove(marker)
|
||||
}
|
||||
}
|
||||
// add new markers
|
||||
val map1 = markers.values.map { it.id }
|
||||
for (charger in chargers) {
|
||||
if (!map1.contains(charger.id)) {
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.z(chargerZ)
|
||||
.icon(makeIcon(charger))
|
||||
.anchor(0.5f, if (mini) 0.5f else 1f)
|
||||
)
|
||||
animateMarker(charger, marker, true)
|
||||
markers[marker] = charger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clusters.toSet() != clusterMarkers.values) {
|
||||
// remove clusters that disappeared
|
||||
clusterMarkers.entries.toList().forEach { (marker, cluster) ->
|
||||
if (!clusters.contains(cluster)) {
|
||||
marker.remove()
|
||||
clusterMarkers.remove(marker)
|
||||
}
|
||||
}
|
||||
|
||||
// add new clusters
|
||||
clusters.forEach { cluster ->
|
||||
if (!clusterMarkers.inverse.contains(cluster)) {
|
||||
val marker = map.addMarker(
|
||||
MarkerOptions()
|
||||
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
|
||||
.z(clusterZ)
|
||||
.icon(
|
||||
map.bitmapDescriptorFactory.fromBitmap(
|
||||
clusterIconGenerator.makeIcon(
|
||||
cluster.clusterCount.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
.anchor(0.5f, 0.5f)
|
||||
)
|
||||
clusterMarkers[marker] = cluster
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateChargerIcons() {
|
||||
markers.forEach { (m, c) ->
|
||||
m.setIcon(makeIcon(c))
|
||||
m.setAnchor(0.5f, if (mini) 0.5f else 1f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSingleChargerIcon(charger: ChargeLocation) {
|
||||
markers.inverse[charger]?.apply {
|
||||
setIcon(makeIcon(charger))
|
||||
setAnchor(0.5f, if (mini) 0.5f else 1f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeIcon(
|
||||
charger: ChargeLocation,
|
||||
scale: Float = 1f
|
||||
) = chargerIconGenerator.getBitmapDescriptor(
|
||||
getMarkerTint(charger, filteredConnectors),
|
||||
scale = scale,
|
||||
highlight = charger.id == highlighedCharger?.id,
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti(filteredConnectors),
|
||||
fav = charger.id in favorites,
|
||||
mini = mini
|
||||
)
|
||||
|
||||
private fun animateMarker(charger: ChargeLocation, marker: Marker, appear: Boolean) {
|
||||
val tint = getMarkerTint(charger, filteredConnectors)
|
||||
val highlight = charger.id == highlighedCharger?.id
|
||||
val fault = charger.faultReport != null
|
||||
val multi = charger.isMulti(filteredConnectors)
|
||||
val fav = charger.id in favorites
|
||||
if (appear) {
|
||||
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav, mini)
|
||||
} else {
|
||||
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav, mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MarkerAnimator(val gen: ChargerIconGenerator) {
|
||||
private val animatingMarkers = hashMapOf<Marker, ValueAnimator>()
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
|
||||
val favorites: LiveData<List<FavoriteWithDetail>> by lazy {
|
||||
db.favoritesDao().getAllFavorites()
|
||||
db.favoritesDao().getAllFavorites().distinctUntilChanged()
|
||||
}
|
||||
|
||||
val searchResult: MutableLiveData<PlaceWithBounds> by lazy {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
app:startDestination="@id/map"
|
||||
android:id="@+id/nav_graph">
|
||||
|
||||
<navigation
|
||||
|
||||
@@ -367,4 +367,5 @@
|
||||
<string name="pref_chargeprice_native_integration">Preisvergleich in EVMap</string>
|
||||
<string name="pref_chargeprice_native_integration_on">Preise werden direkt in EVMap angezeigt</string>
|
||||
<string name="pref_chargeprice_native_integration_off">Preisvergleich verlinkt auf die App oder Website von Chargeprice</string>
|
||||
<string name="auto_zoom_for_details">Für Details hineinzoomen</string>
|
||||
</resources>
|
||||
@@ -40,4 +40,5 @@
|
||||
<string name="referral_eprimo" translatable="false">eprimo</string>
|
||||
<string name="copyright_summary">©2020–2024 Johan von Forstner and contributors</string>
|
||||
<string name="acra_backend_url" translatable="false">https://acra.muc.vonforst.net/report</string>
|
||||
<string name="maplibre_attributionsDialogTitle">MapLibre Maps SDK for Android</string>
|
||||
</resources>
|
||||
|
||||
@@ -367,4 +367,5 @@
|
||||
<string name="pref_chargeprice_native_integration">Price comparison within EVMap</string>
|
||||
<string name="pref_chargeprice_native_integration_on">Pricing data will be shown directly in EVMap</string>
|
||||
<string name="pref_chargeprice_native_integration_off">Price comparison button will refer to the Chargeprice app or website</string>
|
||||
<string name="auto_zoom_for_details">Zoom in to see details</string>
|
||||
</resources>
|
||||
@@ -10,7 +10,7 @@ buildscript {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.4.1")
|
||||
classpath("com.android.tools.build:gradle:8.4.2")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$aboutLibsVersion")
|
||||
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")
|
||||
@@ -24,7 +24,6 @@ allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
//noinspection JcenterRepositoryObsolete
|
||||
maven { setUrl("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +101,9 @@ to switch between the two, while the `foss` flavor only includes OSM.
|
||||
|
||||
### ArcGIS
|
||||
|
||||
[World Imagery basemap](https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9)
|
||||
*We use this for the satellite map, as [Jawg's](https://blog.jawg.io/satellite-imaging/) satellite
|
||||
style does not have global coverage.*
|
||||
[World Imagery basemap](https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9)\
|
||||
*We use this for the satellite map, as [Jawg's satellite
|
||||
style](https://blog.jawg.io/satellite-imaging/) does not have global coverage.*
|
||||
|
||||
<details>
|
||||
<summary>How to obtain an API key</summary>
|
||||
@@ -116,7 +116,7 @@ style does not have global coverage.*
|
||||
|
||||
### Mapbox
|
||||
|
||||
[Geocoding API](https://docs.mapbox.com/api/search/geocoding/)
|
||||
[Geocoding API](https://docs.mapbox.com/api/search/geocoding/)\
|
||||
*previously we also used Mapbox's Maps SDK, but this has now been switched to Jawg Maps.*
|
||||
|
||||
<details>
|
||||
|
||||
2
fastlane/metadata/android/de-DE/changelogs/226.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/226.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
2
fastlane/metadata/android/de-DE/changelogs/228.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/228.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
2
fastlane/metadata/android/de-DE/changelogs/230.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/230.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
2
fastlane/metadata/android/en-US/changelogs/226.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/226.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
2
fastlane/metadata/android/en-US/changelogs/228.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/228.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
2
fastlane/metadata/android/en-US/changelogs/230.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/230.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
Reference in New Issue
Block a user