From 37b2083b541ed9d7f4685d7937f2de3d6c4827c9 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sun, 7 Apr 2024 20:37:26 +0200 Subject: [PATCH] start implementation of new MapScreen using MapWithContentTemplate --- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 1 + .../net/vonforst/evmap/auto/CarAppService.kt | 8 +- .../evmap/auto/ChargerListFormatter.kt | 201 +++++ .../vonforst/evmap/auto/LegacyMapScreen.kt | 531 +++++++++++++ .../evmap/auto/MapAttributionScreen.kt | 42 ++ .../java/net/vonforst/evmap/auto/MapScreen.kt | 703 ++++++++---------- .../vonforst/evmap/auto/MapSurfaceCallback.kt | 202 +++++ .../vonforst/evmap/fragment/MapFragment.kt | 4 +- .../java/net/vonforst/evmap/ui/MarkerUtils.kt | 2 +- app/src/main/res/values/donottranslate.xml | 1 + 11 files changed, 1306 insertions(+), 395 deletions(-) create mode 100644 app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt create mode 100644 app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt create mode 100644 app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt create mode 100644 app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc216d891..82e117e81 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -314,11 +314,13 @@ dependencies { automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion") // AnyMaps - val anyMapsVersion = "c087b3e7c2" + val anyMapsVersion = "a5b9abca40" implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion") googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion") googleImplementation("com.google.android.gms:play-services-maps:18.2.0") - implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") + implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") { + exclude("org.maplibre.gl", "android-sdk-geojson") + } // Google Places googleImplementation("com.google.android.libraries.places:places:3.3.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a52196955..6eface0a2 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..3339795f1 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/CarAppService.kt @@ -125,8 +125,12 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver } override fun onCreateScreen(intent: Intent): Screen { - - val mapScreen = MapScreen(carContext, this) + val mapScreen = + if (carContext.carAppApiLevel >= 7 && carContext.isAppDrivenRefreshSupported) { + MapScreen(carContext, this) + } else { + LegacyMapScreen(carContext, this) + } val screens = mutableListOf(mapScreen) handleActionsIntent(intent)?.let { 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..b2fe15a69 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/ChargerListFormatter.kt @@ -0,0 +1,201 @@ +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.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.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() + } +} \ 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..0b354e22e --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/LegacyMapScreen.kt @@ -0,0 +1,531 @@ +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 + */ +@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.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..93e66b754 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/MapAttributionScreen.kt @@ -0,0 +1,42 @@ +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..b613ad626 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,41 @@ 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.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.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 +43,17 @@ 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.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 +62,61 @@ 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 using */ -@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 val favorites = db.favoritesDao().getAllFavorites() + + override var loadingError = false + override val locationError = false + + private val mapSurfaceCallback = MapSurfaceCallback(carContext) + 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 +130,181 @@ 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 val iconGen = - ChargerIconGenerator(carContext, null, height = 96) + private val formatter = ChargerListFormatter(ctx, this) 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) } 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 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 contentTemplate = ListTemplate.Builder().apply { + setHeader(Header.Builder().apply { + setTitle(title) + setStartHeaderAction(Action.APP_ICON) + }.build()) + formatter.buildChargerList( + chargers?.filterIsInstance(ChargeLocation::class.java), + 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() + }.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 + map.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) - } - } - setCurrentLocationEnabled(true) - setHeaderAction(Action.APP_ICON) - val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else { - filtersWithValue?.count { - !it.value.hasSameValueAs(it.filter.defaultValue()) - } + ).setTint(CarColor.DEFAULT).build() + ).setOnClickListener { + val map = map ?: return@setOnClickListener + map.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()) } - - 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 - } - } - }.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 + R.drawable.ic_search } - .build()) - .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 - } - 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}") + setOnClickListener { + if (prefs.placeSearchResultAndroidAuto != null) { + prefs.placeSearchResultAndroidAutoName = null + prefs.placeSearchResultAndroidAuto = null + chargers = null + loadChargers() + } else { + screenManager.pushForResult( + PlaceSearchScreen( + carContext, + session + ) + ) { + chargers = null + loadChargers() + } + session.mapScreen = null + } } - 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 + }.build()) + .addAction( + Action.Builder() + .setIcon( + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + R.drawable.ic_filter + ) ) - ), - 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() - ) + .setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT) + .build() + ) + .setOnClickListener { + screenManager.push(FilterScreen(carContext, session)) + session.mapScreen = null + } + .build()) + .build() + } - setOnClickListener { - screenManager.push(ChargerDetailScreen(carContext, charger)) - session.mapScreen = null - } - }.build() + override fun onChargerClick(charger: ChargeLocation) { + screenManager.push(ChargerDetailScreen(carContext, charger)) + session.mapScreen = null } override fun updateLocation(location: Location) { @@ -398,11 +313,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) { + map.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) + map.animateCamera(camUpdate) + } } val now = Instant.now() @@ -413,31 +342,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 @@ -452,52 +361,32 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : // 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 isFavorite = chargers?.map { - db.favoritesDao().findFavorite(it.id, apiId) != null + 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 } - 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 +399,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 +410,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. @@ -598,9 +454,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.projection.visibleRegion.latLngBounds.center + prefs.currentMapZoom = it.cameraPosition.zoom + } + prefs.currentMapMyLocationEnabled = myLocationEnabled + } + private fun removeListeners() { if (supportsCarApiLevel3(carContext)) { println("Removing energy level listener") @@ -609,7 +476,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) : } } - override fun onContentRefreshRequested() { + fun onContentRefreshRequested() { loadChargers() availabilities.clear() @@ -641,7 +508,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 +531,64 @@ 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).apply { + this@MapScreen.chargers?.let { chargepoints = it } + onChargerClick = { + markerManager?.highlighedCharger = it + markerManager?.animateBounce(it) + } + } + + map.setMyLocationEnabled(true) + map.uiSettings.setMyLocationButtonEnabled(false) + map.setAttributionClickListener { attributions -> + screenManager.push(MapAttributionScreen(carContext, attributions)) + } + + 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 + ) + + if (prefs.currentMapMyLocationEnabled) { + enableLocation() + } 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 + invalidate() + } + } + + map.setOnCameraIdleListener { + loadChargers() + } + } + + private fun enableLocation() { + myLocationEnabled = true + if (location != null) { + val map = map ?: return + map.animateCamera( + map.cameraUpdateFactory.newLatLngZoom( + LatLng.fromLocation(location), + 13f + ) + ) + } + invalidate() + } } \ 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..f55871580 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/auto/MapSurfaceCallback.kt @@ -0,0 +1,202 @@ +package net.vonforst.evmap.auto + +import android.animation.ValueAnimator +import android.app.Presentation +import android.content.Context +import android.graphics.Point +import android.graphics.Rect +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Build +import android.os.SystemClock +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.content.ContextCompat +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import com.car2go.maps.AnyMap +import com.car2go.maps.MapContainerView +import com.car2go.maps.OnMapReadyCallback +import com.car2go.maps.maplibre.MapView +import com.car2go.maps.maplibre.MapsConfiguration +import net.vonforst.evmap.R +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlin.math.roundToLong + + +class MapSurfaceCallback(val ctx: CarContext) : SurfaceCallback, OnMapReadyCallback { + private val VIRTUAL_DISPLAY_NAME = "evmap_map" + private val VELOCITY_THRESHOLD_IGNORE_FLING = 1000 + + 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 + var cameraMoveStartedListener: (() -> 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 * 1.5).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) + } + + override fun onVisibleAreaChanged(visibleArea: Rect) { + this.visibleArea = visibleArea + updateVisibleArea() + } + + override fun onStableAreaChanged(stableArea: Rect) { + + } + + 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)) + cameraMoveStartedListener?.invoke() + } + + @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 + } + + 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 = current - last + map.moveCamera(map.cameraUpdateFactory.scrollBy(diff * offsetX, diff * offsetY)) + last = current + } + start() + } + } + + @RequiresCarApi(2) + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + flingAnimator?.cancel() + val map = map ?: return + if (scaleFactor == 2f) return + + val focus = Point(focusX.roundToInt(), focusY.roundToInt()) + // TODO: using focal point does not work correctly (at least not with mapbox) + map.moveCamera(map.cameraUpdateFactory.zoomBy(scaleFactor - 1)) + cameraMoveStartedListener?.invoke() + } + + @RequiresCarApi(5) + override fun onClick(x: Float, y: Float) { + flingAnimator?.cancel() + val downTime: Long = SystemClock.uptimeMillis() + val eventTime: Long = downTime + 100 + + val downEvent = MotionEvent.obtain( + downTime, + downTime, + MotionEvent.ACTION_DOWN, + x, + y, + 0 + ) + mapView.dispatchTouchEvent(downEvent) + downEvent.recycle() + val upEvent = MotionEvent.obtain( + downTime, + eventTime, + MotionEvent.ACTION_UP, + x, + y, + 0 + ) + mapView.dispatchTouchEvent(upEvent) + upEvent.recycle() + } + + private fun createMap(ctx: Context): MapContainerView { + MapsConfiguration.getInstance().initialize(ctx) + return MapView(ctx) + } + + override fun onMapReady(anyMap: AnyMap) { + this.map = anyMap + updateVisibleArea() + mapCallbacks.forEach { it.onMapReady(anyMap) } + mapCallbacks.clear() + } + + private fun updateVisibleArea() { + visibleArea?.let { + map?.setPadding(0, it.top, width - it.right, height - it.bottom) + } + } + + fun getMapAsync(callback: OnMapReadyCallback) { + this.map?.let { + callback.onMapReady(it) + } ?: mapCallbacks.add(callback) + } +} \ 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 3ed9108d2..4c9d5807e 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt @@ -690,7 +690,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac } vm.favorites.observe(viewLifecycleOwner) { updateFavoriteToggle() - markerManager?.favorites = it.map { it.favorite.chargerId } + markerManager?.favorites = it.map { it.favorite.chargerId }.toSet() } vm.searchResult.observe(viewLifecycleOwner) { place -> displaySearchResult(place, moveCamera = true) @@ -963,7 +963,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac chargepoints = vm.chargepoints.value?.data ?: emptyList() highlighedCharger = vm.chargerSparse.value searchResult = vm.searchResult.value - favorites = vm.favorites.value?.map { it.favorite.chargerId } ?: emptyList() + favorites = vm.favorites.value?.map { it.favorite.chargerId }?.toSet() ?: emptySet() } map.uiSettings.setTiltGesturesEnabled(false) diff --git a/app/src/main/java/net/vonforst/evmap/ui/MarkerUtils.kt b/app/src/main/java/net/vonforst/evmap/ui/MarkerUtils.kt index 775ef456e..ed4834380 100644 --- a/app/src/main/java/net/vonforst/evmap/ui/MarkerUtils.kt +++ b/app/src/main/java/net/vonforst/evmap/ui/MarkerUtils.kt @@ -79,7 +79,7 @@ class MarkerManager(val context: Context, val map: AnyMap, val lifecycle: Lifecy updateSearchResultMarker() } - var favorites: List = emptyList() + var favorites: Set = emptySet() set(value) { field = value updateChargerIcons() diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 979f77ffd..7307c56cc 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -48,4 +48,5 @@ eprimo ©2020–2024 Johan von Forstner and contributors https://acra.muc.vonforst.net/report + MapLibre Maps SDK for Android