diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d1fe4dfa1..41c3002b0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1159eafda..585d0a843 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
diff --git a/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt b/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt
index 4cc8892c9..35eb77a6a 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt
@@ -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(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) {
diff --git a/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt
index 698aeb45c..bba2efbe5 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/ChargerDetailScreen.kt
@@ -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)
diff --git a/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt b/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt
new file mode 100644
index 000000000..62568bd46
--- /dev/null
+++ b/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt
@@ -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 = emptySet()
+
+ fun buildChargerList(
+ chargers: List?,
+ availabilities: Map>
+ ): 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>,
+ 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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
new file mode 100644
index 000000000..8fe72e88f
--- /dev/null
+++ b/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt
@@ -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? = 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> =
+ 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>? = 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? = 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?,
+ searchLocation: LatLng
+ ): List? {
+ // 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt
new file mode 100644
index 000000000..03b21bb57
--- /dev/null
+++ b/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt
@@ -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
+) : 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()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt
index c27dd27eb..a795d1619 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/MapScreen.kt
@@ -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? = null
- private var isFavorite: List? = 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? = 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> =
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>? = 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())
- }
+ R.string.app_name
}
- 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)
+ )
+
+ val actionStrip = buildActionStrip()
+ val selectedCharger = selectedCharger
+
+ 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()?.isNotEmpty() == true) {
+ MessageTemplate.Builder(carContext.getString(R.string.auto_zoom_for_details))
+ .apply {
+ setHeader(Header.Builder().apply {
+ setTitle(title)
+ setStartHeaderAction(Action.APP_ICON)
+ }.build())
+ }.build()
+ } else {
+ ListTemplate.Builder().apply {
+ setHeader(Header.Builder().apply {
+ setTitle(title)
+ setStartHeaderAction(Action.APP_ICON)
+ }.build())
+
+ formatter.buildChargerList(
+ chargers?.filterIsInstance(),
+ 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 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
)
- setItemList(builder.build())
- } else if (locationError) {
- val builder = ItemList.Builder()
- builder.setNoItemsMessage(
- carContext.getString(R.string.location_error)
+ ).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
)
- setItemList(builder.build())
- } else {
- setLoading(true)
- }
+ ).setTint(CarColor.DEFAULT).build()
+ ).setOnClickListener {
+ val map = map ?: return@setOnClickListener
+ mapSurfaceCallback.animateCamera(map.cameraUpdateFactory.zoomBy(-0.5f))
+ }.build()
+ ).build()
+
+ private fun buildActionStrip(): ActionStrip {
+ val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
+ filtersWithValue?.count {
+ !it.value.hasSameValueAs(it.filter.defaultValue())
}
- 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 {
+ }
+ 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) {
- prefs.placeSearchResultAndroidAutoName = null
- prefs.placeSearchResultAndroidAuto = null
- if (!supportsRefresh) {
- screenManager.pushForResult(DummyReturnScreen(carContext)) {
- chargers = null
- isFavorite = null
- loadChargers()
- }
- } else {
- chargers = null
- isFavorite = null
- loadChargers()
- }
+ R.drawable.ic_search_off
} else {
- screenManager.pushForResult(
- PlaceSearchScreen(
- carContext,
- session
- )
- ) {
- chargers = null
- isFavorite = null
- loadChargers()
- }
- session.mapScreen = null
+ 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()
}
- }.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()
+ session.mapScreen = null
+ }
+ }
+ }.build())
+ .addAction(
+ Action.Builder()
+ .setIcon(
+ CarIcon.Builder(
+ IconCompat.createWithResource(
+ carContext,
+ R.drawable.ic_filter
)
- .setOnClickListener {
- screenManager.push(FilterScreen(carContext, session))
- session.mapScreen = null
- }
- .build())
+ )
+ .setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
+ .build()
+ )
+ .setOnClickListener {
+ screenManager.push(FilterScreen(carContext, session))
+ session.mapScreen = null
+ }
.build())
- if (carContext.carAppApiLevel >= 5 ||
- (BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
- ) {
- setOnContentRefreshListener(this@MapScreen)
- }
- }.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
+ 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()
}
- 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)
- .build()
- )
+ }
- setOnClickListener {
- screenManager.push(ChargerDetailScreen(carContext, charger))
- session.mapScreen = null
- }
- }.build()
+ 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? = 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?,
- searchLocation: LatLng
- ): List? {
- // 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)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt b/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
new file mode 100644
index 000000000..d1cb0bdff
--- /dev/null
+++ b/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt
@@ -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()
+
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt b/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt
index 4eba3a6eb..557c052cd 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/PlaceSearchScreen.kt
@@ -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)
diff --git a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt
index b254ce0e0..e15074f9a 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt
@@ -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 {
diff --git a/app/src/main/java/net/vonforst/evmap/auto/Utils.kt b/app/src/main/java/net/vonforst/evmap/auto/Utils.kt
index f309cac73..d407ac2a0 100644
--- a/app/src/main/java/net/vonforst/evmap/auto/Utils.kt
+++ b/app/src/main/java/net/vonforst/evmap/auto/Utils.kt
@@ -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
}
\ No newline at end of file
diff --git a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt
index 1ce536d2d..ebef9c77f 100644
--- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt
+++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt
@@ -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()
diff --git a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt
index a6aa97882..11913469b 100644
--- a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt
+++ b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt
@@ -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
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 92b59cdfe..4a3f92b2c 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -367,4 +367,5 @@
Preisvergleich in EVMap
Preise werden direkt in EVMap angezeigt
Preisvergleich verlinkt auf die App oder Website von Chargeprice
+ Für Details hineinzoomen
\ No newline at end of file
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index cd8567998..909702796 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -40,4 +40,5 @@
eprimo
©2020–2024 Johan von Forstner and contributors
https://acra.muc.vonforst.net/report
+ MapLibre Maps SDK for Android
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f8b7dacae..c34c2549c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -367,4 +367,5 @@
Price comparison within EVMap
Pricing data will be shown directly in EVMap
Price comparison button will refer to the Chargeprice app or website
+ Zoom in to see details
\ No newline at end of file