mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-23 23:27:46 -05:00
Implement new MapScreen using MapWithContentTemplate
This commit is contained in:
committed by
Johan von Forstner
parent
c11178810e
commit
fa040928e8
@@ -315,7 +315,7 @@ dependencies {
|
||||
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
|
||||
|
||||
// AnyMaps
|
||||
val anyMapsVersion = "3e6c71410f"
|
||||
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:19.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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -11,10 +10,8 @@ import android.net.Uri
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.util.Log
|
||||
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
|
||||
@@ -54,11 +51,7 @@ 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.Coordinate
|
||||
import net.vonforst.evmap.model.Cost
|
||||
import net.vonforst.evmap.model.FaultReport
|
||||
import net.vonforst.evmap.model.Favorite
|
||||
@@ -67,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
|
||||
@@ -77,8 +69,6 @@ import java.time.format.FormatStyle
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "ChargerDetailScreen"
|
||||
|
||||
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
|
||||
var charger: ChargeLocation? = null
|
||||
var photo: Bitmap? = null
|
||||
@@ -138,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)) {
|
||||
@@ -275,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
|
||||
@@ -488,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)) {
|
||||
@@ -546,54 +495,6 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
carContext.getString(R.string.unknown_operator)
|
||||
}
|
||||
|
||||
private fun navigateToCharger(charger: ChargeLocation) {
|
||||
val success = navigateCarApp(charger)
|
||||
if (!success && BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// on AAOS, some OEMs' navigation apps might not support
|
||||
navigateRegularApp(charger)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateCarApp(charger: ChargeLocation): Boolean {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
Intent(
|
||||
CarContext.ACTION_NAVIGATE,
|
||||
Uri.parse("geo:${coord.lat},${coord.lng}")
|
||||
)
|
||||
try {
|
||||
carContext.startCarApp(intent)
|
||||
return true
|
||||
} catch (e: HostException) {
|
||||
Log.w(TAG, "Could not start navigation using car app intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
e.printStackTrace()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not start navigation using car app intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun navigateRegularApp(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(carContext.packageManager) != null) {
|
||||
carContext.startActivity(intent)
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "Could not start navigation using regular intent")
|
||||
Log.w(TAG, intent.toString())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -55,6 +55,7 @@ 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.LatLng
|
||||
@@ -200,12 +201,12 @@ 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user