Compare commits

...

21 Commits
1.3.2 ... 1.3.3

Author SHA1 Message Date
johan12345
0aef554395 Release 1.3.3 2022-06-26 21:28:58 +02:00
johan12345
35f5185893 rebuild ChargecloudAvailabilityDetector and implement status for RheinEnergie 2022-06-26 21:01:58 +02:00
johan12345
f8378eb338 update only when map is idle
fixes error introduced in aa5c36d
2022-06-26 17:49:09 +02:00
Johan von Forstner
0bf56701cc Merge pull request #176 from johan12345/mini-markers
new "mini" marker variant to avoid clustering for zoom levels 11-13
2022-06-26 17:46:14 +02:00
johan12345
aa5c36d1aa new "mini" marker variant to avoid clustering for zoom levels 11-13 2022-06-26 17:44:35 +02:00
johan12345
93787fae74 set marker Z indices explicitly 2022-06-26 15:28:11 +02:00
johan12345
65b6c817fa IconGenerators: calculate precise image size to avoid unnecessary oversizing 2022-06-26 15:27:21 +02:00
johan12345
f022823093 Android Auto: implement deletion of filter profiles
fixes #172
2022-06-25 18:19:45 +02:00
johan12345
63bb161e09 Android Auto: implement slider filters
#172
2022-06-25 18:19:31 +02:00
johan12345
d0de607222 Android Auto: remove WelcomeScreen, default to MapScreen
fixes #179
2022-06-24 21:44:46 +02:00
johan12345
abec208768 Android Auto: start implementing creation of filter profiles
#172
2022-06-24 19:49:39 +02:00
johan12345
fa2b7bf180 remove extra logging from EnBwAvailabilityDetector 2022-06-23 19:34:49 +02:00
johan12345
258a04b14e SearchSelectScreen, FilterScreen: use nicer checkbox/radio button icons 2022-06-22 22:49:22 +02:00
johan12345
1cedb2bccd Android Auto/Automotive: add summary to my charging plans preference 2022-06-22 22:38:52 +02:00
johan12345
20409343fd Android Auto/Automotive: add "select all" option to tariffs selection screen
fixes #183
2022-06-22 22:35:37 +02:00
johan12345
24720d7670 fix lint errors 2022-06-22 22:32:01 +02:00
johan12345
096ef902b7 fix charging plans selection in Android Auto/Automotive
fixes #182
2022-06-22 22:23:56 +02:00
johan12345
e70ab68ff8 simplify location access for Android Auto app
extra CarLocationService is not needed, this can be done within CarAppService
2022-06-22 22:09:29 +02:00
johan12345
a69447bb95 fix IllegalStateException in MapFragment 2022-06-22 21:16:59 +02:00
johan12345
326493f5c1 fix donations text in foss version 2022-06-21 21:47:03 +02:00
Johan von Forstner
6adfda8c33 app description: add link to permissions page 2022-06-17 10:06:44 +02:00
36 changed files with 1099 additions and 648 deletions

View File

@@ -18,8 +18,8 @@ android {
minSdkVersion 21
targetSdkVersion 31
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 80
versionName "1.3.2"
versionCode 82
versionName "1.3.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -9,7 +9,7 @@
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -42,11 +42,5 @@
android:category="androidx.car.app.category.POI" />
</intent-filter>
</service>
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,22 +1,33 @@
package net.vonforst.evmap.auto
import android.content.*
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.location.Location
import android.os.IBinder
import android.location.LocationManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.validation.HostValidator
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import net.vonforst.evmap.R
import net.vonforst.evmap.utils.checkAnyLocationPermission
@@ -24,7 +35,46 @@ interface LocationAwareScreen {
fun updateLocation(location: Location)
}
@ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
override fun onCreate() {
super.onCreate()
// we want to run as a foreground service to make sure we can use location
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
}
private fun createNotificationChannel() {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
}
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
@@ -40,49 +90,35 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
@ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver {
private val TAG = "EVMapSession"
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private var locationService: CarLocationService? = null
private val locationManager: LocationManager by lazy {
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
}
private val hardwareMan: CarHardwareManager by lazy {
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
locationService = binder.service
locationService?.requestLocationUpdates()
}
override fun onServiceDisconnected(name: ComponentName?) {
locationService = null
}
}
private var serviceBound = false
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
return WelcomeScreen(carContext, this)
return MapScreen(carContext, this)
}
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
private val locationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
updateLocation(location)
}
}
private fun updateLocation(location: Location?) {
Log.d(TAG, "Received location: $location")
val mapScreen = mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
@@ -90,19 +126,23 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
this.location = location
}
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
unbindLocationService()
}
override fun onStart(owner: LifecycleOwner) {
requestLocationUpdates()
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
override fun onStop(owner: LifecycleOwner) {
removeLocationUpdates()
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()
}
private fun requestCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
@@ -111,40 +151,48 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
::onCarHardwareLocationReceived
)
}
serviceBound = cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestPhoneLocationUpdates() {
val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
updateLocation(location)
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000,
1f,
this::updateLocation
)
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun onStop() {
@SuppressLint("MissingPermission")
private fun removeLocationUpdates() {
if (!locationPermissionGranted()) return
removeCarHardwareLocationUpdates()
removePhoneLocationUpdates()
}
private fun removeCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
}
unbindLocationService()
}
private fun unbindLocationService() {
locationService?.removeLocationUpdates()
if (serviceBound) {
cas.unbindService(serviceConnection)
serviceBound = false
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun removePhoneLocationUpdates() {
locationManager.removeUpdates(this::updateLocation)
}
@SuppressLint("MissingPermission")
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
removePhoneLocationUpdates()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun registerBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).registerReceiver(
locationReceiver,
IntentFilter(CarLocationService.ACTION_BROADCAST)
);
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun unregisterBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
}
}

View File

@@ -1,163 +0,0 @@
package net.vonforst.evmap.auto
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.gms.location.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
class CarLocationService : Service() {
private lateinit var serviceHandler: Handler
private lateinit var locationRequest: LocationRequest
private lateinit var notificationManager: NotificationManager
private lateinit var locationCallback: LocationCallback
private lateinit var fusedLocationClient: FusedLocationProviderClient
private val binder: IBinder = LocalBinder(this)
private var location: Location? = null
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private val TAG = "CarLocationService"
companion object {
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
}
override fun onCreate() {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
super.onLocationResult(locationResult)
onNewLocation(locationResult.lastLocation)
}
}
createLocationRequest()
getLastLocation()
val handlerThread = HandlerThread(TAG)
handlerThread.start()
serviceHandler = Handler(handlerThread.looper)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
startForeground(NOTIFICATION_ID, getNotification())
}
/**
* Returns the [NotificationCompat] used as part of the foreground service.
*/
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
private fun createLocationRequest() {
locationRequest = LocationRequest()
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}
private fun onNewLocation(location: Location) {
Log.i(TAG, "New location: $location")
this.location = location
// Notify anyone listening for broadcasts about the new location.
val intent = Intent(ACTION_BROADCAST)
intent.putExtra(EXTRA_LOCATION, location)
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
}
private fun getLastLocation() {
try {
fusedLocationClient.lastLocation
.addOnCompleteListener { task ->
if (task.isSuccessful && task.result != null) {
location = task.result
} else {
Log.w(TAG, "Failed to get location.")
}
}
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission.$unlikely")
}
}
/**
* Makes a request for location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun requestLocationUpdates() {
Log.i(TAG, "Requesting location updates")
startService(Intent(applicationContext, CarLocationService::class.java))
try {
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback, Looper.myLooper()
)
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
}
}
/**
* Removes location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun removeLocationUpdates() {
Log.i(TAG, "Removing location updates")
try {
fusedLocationClient.removeLocationUpdates(locationCallback)
stopSelf()
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
}
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Service started")
// Tells the system to not try to recreate the service after it has been killed.
return START_NOT_STICKY
}
override fun onDestroy() {
serviceHandler.removeCallbacksAndMessages(null)
}
class LocalBinder(val service: CarLocationService) : Binder()
}

View File

@@ -60,7 +60,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val imageWidthLarge = 854
private val iconGen =
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)

View File

@@ -1,27 +1,35 @@
package net.vonforst.evmap.auto
import android.app.Application
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.FilterViewModel
import kotlin.math.roundToInt
@androidx.car.app.annotations.ExperimentalCarApi
class FilterScreen(ctx: CarContext) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = 6
private val checkIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
init {
filterProfiles.observe(this) {
@@ -30,15 +38,71 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
}
override fun onGetTemplate(): Template {
val filterStatus =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
val filterStatus = prefs.filterStatus
return ListTemplate.Builder().apply {
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
setSingleList(buildFilterProfilesList(it, filterStatus))
} ?: setLoading(true)
setTitle(carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().apply {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_edit
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
lifecycleScope.launch {
db.filterValueDao()
.copyFiltersToCustom(filterStatus, prefs.dataSource)
screenManager.push(EditFiltersScreen(carContext))
}
})
}.build())
if (filterStatus !in listOf(
FILTERS_CUSTOM,
FILTERS_FAVORITES,
FILTERS_DISABLED
)
) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
val currentProfile =
filterProfiles.value?.find { it.id == filterStatus }
?: return@setOnClickListener
lifecycleScope.launch {
db.filterProfileDao().delete(currentProfile)
prefs.filterStatus = FILTERS_DISABLED
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
}
}
}.build())
}
}.build()
)
}.build()
}
@@ -46,36 +110,307 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
profiles: List<FilterProfile>,
filterStatus: Long
): ItemList {
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
val profilesToShow =
profiles.sortedByDescending { it.id == filterStatus }.take(maxRows - extraRows)
return ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
if (FILTERS_DISABLED == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyCarIcon)
}
setOnClickListener {
prefs.filterStatus = FILTERS_DISABLED
screenManager.pop()
}
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_favorites))
}.build())
profilesToShow.forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
setTitle(name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyCarIcon)
}.build())
}
if (FILTERS_CUSTOM == filterStatus) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_custom))
}.build())
}
setSelectedIndex(when (filterStatus) {
FILTERS_DISABLED -> 0
FILTERS_FAVORITES -> 1
FILTERS_CUSTOM -> profilesToShow.size + 2
else -> profilesToShow.indexOfFirst { it.id == filterStatus } + 2
})
setOnSelectedListener { index ->
onItemClick(
when (index) {
0 -> FILTERS_DISABLED
1 -> FILTERS_FAVORITES
profilesToShow.size + 2 -> FILTERS_CUSTOM
else -> profilesToShow[index - 2].id
}
setOnClickListener {
prefs.filterStatus = it.id
screenManager.pop()
)
}
}.build()
}
private fun onItemClick(id: Long) {
prefs.filterStatus = id
screenManager.pop()
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
private val vm = FilterViewModel(carContext.applicationContext as Application)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
init {
vm.filtersWithValue.observe(this) {
vm.filterProfile.observe(this) {
invalidate()
}
}
}
override fun onGetTemplate(): Template {
val currentProfileName = vm.filterProfile.value?.name
return ListTemplate.Builder().apply {
vm.filtersWithValue.value?.let { filtersWithValue ->
setSingleList(buildFiltersList(filtersWithValue.take(maxRows)))
} ?: setLoading(true)
setTitle(currentProfileName?.let {
carContext.getString(
R.string.edit_filter_profile,
it
)
} ?: carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
setActionStrip(ActionStrip.Builder().apply {
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_check
)
).build()
)
.setOnClickListener {
lifecycleScope.launch {
vm.saveFilterValues()
screenManager.popTo(MapScreen.MARKER)
}
}
.build()
)
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_save
)
).build()
)
.setOnClickListener {
val textPromptScreen = TextPromptScreen(
carContext,
R.string.save_as_profile,
R.string.save_profile_enter_name,
currentProfileName
)
screenManager.pushForResult(textPromptScreen) { name ->
if (name == null) return@pushForResult
lifecycleScope.launch {
vm.saveAsProfile(name as String)
screenManager.popTo(MapScreen.MARKER)
}
}
}
.build()
)
}.build())
}.build()
}
private fun buildFiltersList(filters: List<FilterWithValue<out FilterValue>>): ItemList {
return ItemList.Builder().apply {
filters.forEach {
val filter = it.filter
val value = it.value
addItem(Row.Builder().apply {
setTitle(filter.name)
when (filter) {
is BooleanFilter -> {
setToggle(Toggle.Builder {
(value as BooleanFilterValue).value = it
}.setChecked((value as BooleanFilterValue).value).build())
}
is MultipleChoiceFilter -> {
setBrowsable(true)
setOnClickListener {
screenManager.push(
MultipleChoiceFilterScreen(
carContext,
filter,
value as MultipleChoiceFilterValue
)
)
}
addText(
if ((value as MultipleChoiceFilterValue).all) {
carContext.getString(R.string.all_selected)
} else {
carContext.getString(
R.string.number_selected,
value.values.size
)
}
)
}
is SliderFilter -> {
setBrowsable(true)
addText((value as SliderFilterValue).value.toString() + " " + filter.unit)
setOnClickListener {
screenManager.push(
SliderFilterScreen(
carContext,
filter,
value
)
)
}
}
}
}.build())
}
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
}.build()
}
}
class MultipleChoiceFilterScreen(
ctx: CarContext,
val filter: MultipleChoiceFilter,
val value: MultipleChoiceFilterValue
) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: Pair<String, String>): Boolean =
value.all || value.values.contains(it.first)
override fun toggleSelected(item: Pair<String, String>) {
if (isSelected(item)) {
val values = if (value.all) filter.choices.keys else value.values
value.values = values.minus(item.first).toMutableSet()
value.all = false
} else {
value.values.add(item.first)
if (value.values == filter.choices.keys) {
value.all = true
}
}
}
override fun selectAll() {
value.all = true
super.selectAll()
}
override fun selectNone() {
value.all = false
value.values = mutableSetOf()
super.selectNone()
}
override fun getLabel(it: Pair<String, String>): String = it.second
override suspend fun loadData(): List<Pair<String, String>> {
return filter.choices.entries.map { it.toPair() }
}
}
class SliderFilterScreen(
ctx: CarContext,
val filter: SliderFilter,
val value: SliderFilterValue
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(
Pane.Builder().apply {
addRow(Row.Builder().apply {
setTitle(filter.name)
addText(value.value.toString() + " " + filter.unit)
addText(generateSlider())
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener(::decrease)
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener(::increase)
}.build())
}.build()
).apply {
setHeaderAction(Action.BACK)
}.build()
}
private fun generateSlider(): CharSequence {
val bar = ""
val dot = ""
val length = 35
val position =
((filter.inverseMapping(value.value) - filter.min) / (filter.max - filter.min).toDouble() * length).roundToInt()
val text = SpannableStringBuilder()
text.append(
bar.repeat(position),
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(
dot,
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(bar.repeat(length - position))
return text
}
private fun increase() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal < filter.max) valueInternal += 1
value.value = filter.mapping(valueInternal)
invalidate()
}
private fun decrease() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal > filter.min) valueInternal -= 1
value.value = filter.mapping(valueInternal)
invalidate()
}
}

View File

@@ -1,7 +1,10 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -23,8 +26,6 @@ import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -45,9 +46,13 @@ import kotlin.math.roundToInt
* Main map screen showing either nearby chargers or favorites
*/
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
class MapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ItemList.OnItemVisibilityChangedListener {
companion object {
val MARKER = "map"
}
private var updateCoroutine: Job? = null
private var availabilityUpdateCoroutine: Job? = null
@@ -73,8 +78,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
value = prefs.filterStatus
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters =
@@ -99,14 +103,17 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
filtersWithValue.observe(this) {
loadChargers()
}
marker = MARKER
}
override fun onGetTemplate(): Template {
checkLocationPermission()
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
if (filterStatus.value == FILTERS_FAVORITES) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
@@ -125,7 +132,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
if (filterStatus.value == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
@@ -136,42 +143,73 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
setItemList(builder.build())
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
if (!favorites) {
val filtersCount = filtersWithValue.value?.count {
setHeaderAction(Action.APP_ICON)
val filtersCount = if (filterStatus.value == FILTERS_FAVORITES) 1 else {
filtersWithValue.value?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
setActionStrip(
ActionStrip.Builder()
.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()
setActionStrip(
ActionStrip.Builder()
.addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
filterStatus.value =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
}
screenManager.push(SettingsScreen(carContext))
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.pushForResult(FilterScreen(carContext)) {
chargers = null
filterStatus.value = prefs.filterStatus
}
session.mapScreen = null
}
.build())
.build())
}
setOnContentRefreshListener(this@MapScreen)
}.build()
}
private fun checkLocationPermission() {
if (!session.locationPermissionGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
) {
session.requestLocationUpdates()
}
}
}
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
@@ -250,7 +288,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
setOnClickListener {
screenManager.pushForResult(ChargerDetailScreen(carContext, charger)) {
if (favorites) {
if (filterStatus.value == FILTERS_FAVORITES) {
// favorites list may have been updated
chargers = null
loadChargers()
@@ -290,7 +328,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
updateCoroutine = lifecycleScope.launch {
try {
// load chargers
if (favorites) {
if (filterStatus.value == FILTERS_FAVORITES) {
chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(

View File

@@ -1,11 +1,14 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
@@ -16,6 +19,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean
override fun onGetTemplate(): Template {
if (fullList == null) {
@@ -33,6 +37,30 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
} ?: run {
setLoading(true)
}
if (isMultiSelect) {
setActionStrip(ActionStrip.Builder().apply {
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_all
)
).build()
).setOnClickListener(::selectAll).build()
)
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_none
)
).build()
).setOnClickListener(::selectNone).build()
)
}.build())
}
}.build()
}
@@ -45,18 +73,22 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
} ?: emptyList()
}
private val checkedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_checked))
.setTint(CarColor.PRIMARY)
.build()
private val uncheckedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_unchecked))
.setTint(CarColor.PRIMARY)
.build()
private fun buildItemList(): ItemList {
return ItemList.Builder().apply {
currentList.forEach { item ->
addItem(
Row.Builder()
.setTitle(
if (isSelected(item)) {
"" + getLabel(item)
} else {
"" + getLabel(item)
}
)
.setTitle(getLabel(item))
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
if (isMultiSelect) {
@@ -86,6 +118,16 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
abstract fun toggleSelected(item: T)
open fun selectAll() {
CarToast.makeText(carContext, R.string.selecting_all, CarToast.LENGTH_SHORT).show()
invalidate()
}
open fun selectNone() {
CarToast.makeText(carContext, R.string.selecting_none, CarToast.LENGTH_SHORT).show()
invalidate()
}
abstract fun isSelected(it: T): Boolean
abstract fun getLabel(it: T): String

View File

@@ -60,6 +60,22 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
screenManager.push(ChargepriceSettingsScreen(carContext))
}
}.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_vehicle_data))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(carContext, R.drawable.ic_car)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
}
}.build())
}.build()
}
@@ -119,6 +135,19 @@ class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
setOnClickListener {
screenManager.push(SelectTariffsScreen(carContext))
}
addText(
if (prefs.chargepriceMyTariffsAll) {
carContext.getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
carContext.resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.getString(R.string.pref_my_tariffs_summary)
}
)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
@@ -183,6 +212,7 @@ class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargepric
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
override val isMultiSelect = true
override val shouldShowSelectAll = false
override fun isSelected(it: ChargepriceCar): Boolean {
return prefs.chargepriceMyVehicles.contains(it.id)
@@ -207,6 +237,7 @@ class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargeprice
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: ChargepriceTariff): Boolean {
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
@@ -221,11 +252,26 @@ class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargeprice
}
if (isSelected(item)) {
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
prefs.chargepriceMyTariffsAll = false
} else {
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
prefs.chargepriceMyTariffsAll = true
}
}
}
override fun selectAll() {
prefs.chargepriceMyTariffsAll = true
super.selectAll()
}
override fun selectNone() {
prefs.chargepriceMyTariffsAll = false
prefs.chargepriceMyTariffs = emptySet()
super.selectNone()
}
override fun getLabel(it: ChargepriceTariff): String {
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
@@ -242,6 +288,7 @@ class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<Chargeprice
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
private val prefs = PreferenceDataSource(carContext)
override val isMultiSelect = false
override val shouldShowSelectAll = false
override fun isSelected(it: Pair<String, String>): Boolean =
prefs.chargepriceCurrency == it.second

View File

@@ -0,0 +1,35 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.InputCallback
import androidx.car.app.model.Template
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.SignInTemplate
class TextPromptScreen(
ctx: CarContext,
@StringRes val title: Int,
@StringRes val prompt: Int,
val initialValue: String? = null
) : Screen(ctx),
InputCallback {
override fun onGetTemplate(): Template {
val signInMethod = InputSignInMethod.Builder(this).apply {
initialValue?.let { setDefaultValue(it) }
setShowKeyboardByDefault(true)
}.build()
return SignInTemplate.Builder(signInMethod).apply {
setHeaderAction(Action.BACK)
setInstructions(carContext.getString(prompt))
setTitle(carContext.getString(title))
}.build()
}
override fun onInputSubmitted(text: String) {
setResult(text)
screenManager.pop()
}
}

View File

@@ -1,140 +0,0 @@
package net.vonforst.evmap.auto
import android.Manifest
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.model.*
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
@androidx.car.app.annotations.ExperimentalCarApi
override fun onGetTemplate(): Template {
if (!session.locationPermissionGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
) {
session.bindLocationService()
}
}
}
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
if (!session.locationPermissionGranted()) {
setLoading(true)
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(
MapScreen(
carContext,
session,
favorites = false
)
)
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_vehicle_data))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(carContext, R.drawable.ic_car)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
session.mapScreen = null
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
}
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_settings))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
session.mapScreen = null
screenManager.push(SettingsScreen(carContext))
})
.build()
)
}.build())
setCurrentLocationEnabled(true)
}
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
this.location = location
invalidate()
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -37,4 +37,6 @@
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
<string name="selecting_all">alle Einträge ausgewählt</string>
<string name="selecting_none">alle Einträge abgewählt</string>
</resources>

View File

@@ -47,4 +47,6 @@
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="selecting_all">selected all items</string>
<string name="selecting_none">deselected all items</string>
</resources>

View File

@@ -21,7 +21,14 @@ import java.util.concurrent.TimeUnit
interface AvailabilityDetector {
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
fun isCountrySupported(country: String, dataSource: String): Boolean
/**
* Get a rough estimate whether this charger is supported by this provider.
*
* This might be done by checking supported countries, or even by matching the operator
* for operator-specific availability detectors.
*/
fun isChargerSupported(charger: ChargeLocation): Boolean
}
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
@@ -158,26 +165,16 @@ private val okhttp = OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
/*ChargecloudAvailabilityDetector(
okhttp,
"606a0da0dfdd338ee4134605653d4fd8"
), // Maingau
ChargecloudAvailabilityDetector(
okhttp,
"6336fe713f2eb7fa04b97ff6651b76f8"
) // SW Kiel*/
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
val country = charger.chargepriceData?.country
?: charger.address?.country
?: return Resource.error(null, null)
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isCountrySupported(country, charger.dataSource)) continue
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break

View File

@@ -1,87 +1,110 @@
package net.vonforst.evmap.api.availability
import net.vonforst.evmap.api.iterator
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
class ChargecloudAvailabilityDetector(
client: OkHttpClient,
private val operatorId: String
) : BaseAvailabilityDetector(client) {
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val url =
"https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/locations?latitude=${location.coordinates.lat}&longitude=${location.coordinates.lng}&radius=$radius&offset=0&limit=10"
val json = JSONObject(httpGet(url))
interface ChargecloudApi {
@GET("locations")
suspend fun getData(
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius") radius: Int,
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 10
): ChargecloudResponse
val statusMessage = json.getString("status_message")
if (statusMessage != "Success") throw IOException(statusMessage)
@JsonClass(generateAdapter = true)
data class ChargecloudResponse(
val data: List<ChargecloudLocation>
)
val data = json.getJSONArray("data")
if (data.length() > 1) throw AvailabilityDetectorException(
"found multiple candidates."
)
if (data.length() == 0) throw AvailabilityDetectorException(
"no candidates found."
)
@JsonClass(generateAdapter = true)
data class ChargecloudLocation(
val coordinates: ChargecloudCoordinates,
val evses: List<ChargecloudEvse>,
@Json(name = "distance_in_m") val distanceInM: String
)
val evses = data.getJSONObject(0).getJSONArray("evses")
val chargepointStatus = mutableMapOf<Chargepoint, List<ChargepointStatus>>()
evses.iterator<JSONObject>().forEach { evse ->
evse.getJSONArray("connectors").iterator<JSONObject>().forEach connector@{ connector ->
val type = getType(connector.getString("standard"))
val power = connector.getDouble("max_power")
val status = ChargepointStatus.valueOf(connector.getString("status"))
@JsonClass(generateAdapter = true)
data class ChargecloudCoordinates(val latitude: Double, val longitude: Double)
var chargepoint = getCorrespondingChargepoint(chargepointStatus.keys, type, power)
val statusList: List<ChargepointStatus>
if (chargepoint == null) {
// find corresponding chargepoint from goingelectric to get correct power
val geChargepoint =
getCorrespondingChargepoint(location.chargepointsMerged, type, power)
?: throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
chargepoint = Chargepoint(
type,
geChargepoint.power,
1
)
statusList = listOf(status)
} else {
val previousStatus = chargepointStatus[chargepoint]!!
statusList = previousStatus + listOf(status)
chargepointStatus.remove(chargepoint)
chargepoint =
Chargepoint(
chargepoint.type,
chargepoint.power,
chargepoint.count + 1
)
}
@JsonClass(generateAdapter = true)
data class ChargecloudEvse(
val id: String,
val status: String,
val connectors: List<ChargecloudConnector>
)
chargepointStatus[chargepoint] = statusList
}
}
@JsonClass(generateAdapter = true)
data class ChargecloudConnector(
val id: Long,
val standard: String,
@Json(name = "max_power") val maxPower: Double,
val status: String
)
if (chargepointStatus.keys == location.chargepointsMerged.toSet()) {
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"
)
} else {
throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): ChargecloudApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
return retrofit.create(ChargecloudApi::class.java)
}
}
}
override fun isCountrySupported(country: String, dataSource: String): Boolean {
TODO("Not yet implemented")
abstract class ChargecloudAvailabilityDetector(
client: OkHttpClient
) : BaseAvailabilityDetector(client) {
protected abstract val operatorId: String
private val api: ChargecloudApi by lazy {
val baseUrl = "https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/"
ChargecloudApi.create(client, baseUrl)
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val data = api.getData(location.coordinates.lat, location.coordinates.lng, radius)
val nearest = data.data.minByOrNull { it.distanceInM.toDouble() }
?: throw AvailabilityDetectorException("no candidates found.")
val chargecloudConnectors = mutableMapOf<Long, Pair<Double, String>>()
val chargecloudStatus = mutableMapOf<Long, ChargepointStatus>()
nearest.evses.flatMap { it.connectors }.forEach {
val id = it.id
val power = it.maxPower
val type = getType(it.standard)
val status = when (it.status) {
"OUTOFORDER" -> ChargepointStatus.FAULTED
"AVAILABLE" -> ChargepointStatus.AVAILABLE
"CHARGING" -> ChargepointStatus.CHARGING
"UNKNOWN" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
chargecloudConnectors.put(id, power to type)
chargecloudStatus.put(id, status)
}
val match = matchChargepoints(chargecloudConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { chargecloudStatus[it]!! }
}
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"
)
}
private fun getType(string: String): String {
@@ -90,7 +113,24 @@ class ChargecloudAvailabilityDetector(
"DOMESTIC_F" -> Chargepoint.SCHUKO
"IEC_62196_T2_COMBO" -> Chargepoint.CCS_TYPE_2
"CHADEMO" -> Chargepoint.CHADEMO
else -> throw IllegalArgumentException("unrecognized type $string")
else -> "unknown"
}
}
}
}
class RheinenergieAvailabilityDetector(client: OkHttpClient) :
ChargecloudAvailabilityDetector(client) {
override val operatorId = "c4ce9bb82a86766833df8a4818fa1b5c"
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val network = charger.chargepriceData?.network ?: charger.network ?: return false
return when (charger.dataSource) {
"goingelectric" -> network == "RheinEnergie"
"openchargemap" -> network == "72"
else -> false
}
}
}
// "606a0da0dfdd338ee4134605653d4fd8" Maingau
// "6336fe713f2eb7fa04b97ff6651b76f8" SW Kiel*/

View File

@@ -118,8 +118,6 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
}
}
println(markers)
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)
} ?: throw AvailabilityDetectorException("no candidates found.")
@@ -195,8 +193,10 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
)
}
override fun isCountrySupported(country: String, dataSource: String): Boolean =
when (dataSource) {
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val country = charger.chargepriceData?.country
?: charger.address?.country ?: return false
return when (charger.dataSource) {
// list of countries as of 2021/06/30, according to
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
"goingelectric" -> country in listOf(
@@ -223,4 +223,5 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
)
else -> false
}
}
}

View File

@@ -173,7 +173,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
)
}
override fun isCountrySupported(country: String, dataSource: String): Boolean {
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return true
}

View File

@@ -126,6 +126,7 @@ class GoingElectricApiWrapper(
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
) : ChargepointApi<GEReferenceData> {
private val clusterThreshold = 11f
val api = GoingElectricApi.create(apikey, baseurl, context)
override fun getName() = "GoingElectric.de"
@@ -173,7 +174,7 @@ class GoingElectricApiWrapper(
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -267,7 +268,7 @@ class GoingElectricApiWrapper(
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -330,7 +331,7 @@ class GoingElectricApiWrapper(
}.map { it.convert(apikey, false) }
// apply clustering
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
if (!geClusteringAvailable && useClustering) {

View File

@@ -105,6 +105,7 @@ class OpenChargeMapApiWrapper(
baseurl: String = "https://api.openchargemap.io/v3/",
context: Context? = null
) : ChargepointApi<OCMReferenceData> {
private val clusterThreshold = 11
val api = OpenChargeMapApi.create(apikey, baseurl, context)
override fun getName() = "OpenChargeMap.org"
@@ -238,7 +239,7 @@ class OpenChargeMapApiWrapper(
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
// apply clustering
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
if (useClustering) {
val clusterDistance = getClusterDistance(zoom)
Dispatchers.IO.run {

View File

@@ -85,10 +85,7 @@ import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.ui.*
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
@@ -521,7 +518,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = fav == null
fav = fav == null,
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -588,6 +586,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateMap(chargepoints)
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
}
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
})
@@ -611,6 +612,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
@@ -652,7 +654,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -667,10 +670,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
animator.animateMarkerBounce(marker)
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
// un-highlight all other markers
markers.forEach { (m, c) ->
@@ -681,7 +685,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -803,7 +808,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: AnyMap) {
this.map = map
chargerIconGenerator = ChargerIconGenerator(requireContext(), map.bitmapDescriptorFactory)
val context = this.context ?: return
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
// Google Maps: icons can be generated in background thread
@@ -824,15 +830,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
map.setIndoorEnabled(false)
map.uiSettings.setIndoorLevelPickerEnabled(false)
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
vm.reloadChargepoints()
}
map.setOnCameraMoveListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
}
vm.mapPosition.observe(viewLifecycleOwner) {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnCameraMoveStartedListener { reason ->
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE) {
if (vm.myLocationEnabled.value == true) {
@@ -966,7 +979,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
if (context?.checkAnyLocationPermission() ?: false) {
if (context.checkAnyLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -1028,15 +1041,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// update icons of existing markers (connector filter may have changed)
for ((marker, charger) in markers) {
val highlight = charger == vm.chargerSparse.value
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
highlight = highlight,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
}
if (chargers.toSet() != markers.values) {
@@ -1054,7 +1070,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav =
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav)
animator.animateMarkerDisappear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
} else {
animator.deleteMarker(marker)
}
@@ -1073,6 +1092,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.z(chargerZ)
.icon(
chargerIconGenerator.getBitmapDescriptor(
tint,
@@ -1081,12 +1101,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight,
fault,
multi,
fav
fav,
vm.useMiniMarkers.value == true
)
)
.anchor(0.5f, 1f)
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
)
animator.animateMarkerAppear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav)
markers[marker] = charger
}
}
@@ -1096,6 +1120,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.addMarker(
MarkerOptions()
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
.z(clusterZ)
.icon(
map.bitmapDescriptorFactory.fromBitmap(
clusterIconGenerator.makeIcon(

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.*
import net.vonforst.evmap.await
import net.vonforst.evmap.model.*
@Dao
@@ -92,4 +93,15 @@ abstract class FilterValueDao {
deleteSliderFilterValuesForProfile(profile, dataSource)
}
@Transaction
open suspend fun copyFiltersToCustom(filterStatus: Long, dataSource: String) {
if (filterStatus == FILTERS_CUSTOM) return
deleteFilterValuesForProfile(FILTERS_CUSTOM, dataSource)
val values = getFilterValues(filterStatus, dataSource).await().onEach {
it.profile = FILTERS_CUSTOM
}
insert(*values.toTypedArray())
}
}

View File

@@ -46,8 +46,9 @@ class ChargerIconGenerator(
val context: Context,
val factory: BitmapDescriptorFactory?,
val scaleResolution: Int = 20,
val oversize: Float = 1.4f, // increase to add padding for fault icon or scale > 1
val height: Int = 44
val scaleResolutionMini: Int = 10,
val oversize: Float = 1f, // increase to add padding for scale > 1
val height: Int = 48
) {
private data class BitmapData(
val tint: Int,
@@ -56,16 +57,21 @@ class ChargerIconGenerator(
val highlight: Boolean,
val fault: Boolean,
val multi: Boolean,
val fav: Boolean
val fav: Boolean,
val mini: Boolean
)
// 230 items: (21 sizes, 5 colors, multi on/off) + highlight + fault (only with scale = 1)
private val cacheSize = (scaleResolution + 3) * 5 * 2;
// 340 items:
// large: (21 sizes, 5 colors, multi on/off) + highlight + fault + fav (only with scale = 1)
// mini: (11 sizes, 5 colors) + highlight (only with scale = 1)
private val cacheSize = (scaleResolution + 8) * 5 * 2 + (scaleResolutionMini + 2) * 5;
private val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
private val icon = R.drawable.ic_map_marker_charging
private val multiIcon = R.drawable.ic_map_marker_charging_multiple
private val highlightIcon = R.drawable.ic_map_marker_highlight
private val miniIcon = R.drawable.ic_map_marker_charging_mini
private val highlightIcon = R.drawable.ic_map_marker_charging_highlight
private val highlightIconMulti = R.drawable.ic_map_marker_charging_highlight_multiple
private val highlightIconMini = R.drawable.ic_map_marker_charging_highlight_mini
private val faultIcon = R.drawable.ic_map_marker_fault
private val favIcon = R.drawable.ic_map_marker_fav
@@ -82,12 +88,15 @@ class ChargerIconGenerator(
for (highlight in listOf(false, true)) {
for (multi in listOf(false, true)) {
for (fav in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..scaleResolution) {
getBitmapDescriptor(
tint, scale.toFloat() / scaleResolution,
255, highlight, fault, multi, fav
)
for (mini in listOf(false, true)) {
for (tint in tints) {
val scaleRes = if (mini) scaleResolutionMini else scaleResolution
for (scale in 0..scaleRes) {
getBitmapDescriptor(
tint, scale.toFloat() / scaleRes,
255, highlight, fault, multi, fav, mini
)
}
}
}
}
@@ -103,16 +112,10 @@ class ChargerIconGenerator(
highlight: Boolean = false,
fault: Boolean = false,
multi: Boolean = false,
fav: Boolean = false
fav: Boolean = false,
mini: Boolean = false
): BitmapDescriptor? {
val data = BitmapData(
tint, (scale * scaleResolution).roundToInt(),
alpha,
if (scale == 1f) highlight else false,
if (scale == 1f) fault else false,
multi,
if (scale == 1f) fav else false
)
val data = createBitmapData(tint, scale, alpha, highlight, fault, multi, fav, mini)
val cachedImg = cache[data]
return if (cachedImg != null) {
cachedImg
@@ -124,6 +127,26 @@ class ChargerIconGenerator(
}
}
private fun createBitmapData(
tint: Int,
scale: Float,
alpha: Int,
highlight: Boolean,
fault: Boolean,
multi: Boolean,
fav: Boolean,
mini: Boolean
) = BitmapData(
tint,
(scale * (if (mini) scaleResolutionMini else scaleResolution)).roundToInt(),
alpha,
if (scale == 1f) highlight else false,
if (scale == 1f && !mini) fault else false,
if (!mini) multi else false,
if (scale == 1f && !mini) fav else false,
mini
)
fun getBitmap(
@ColorRes tint: Int,
scale: Float = 1f,
@@ -131,94 +154,99 @@ class ChargerIconGenerator(
highlight: Boolean = false,
fault: Boolean = false,
multi: Boolean = false,
fav: Boolean = false
fav: Boolean = false,
mini: Boolean = false
): Bitmap {
val data = BitmapData(
tint, (scale * scaleResolution).roundToInt(),
alpha,
if (scale == 1f) highlight else false,
if (scale == 1f) fault else false,
multi,
if (scale == 1f) fav else false,
)
val data = createBitmapData(tint, scale, alpha, highlight, fault, multi, fav, mini)
return generateBitmap(data)
}
private fun generateBitmap(data: BitmapData): Bitmap {
val icon = if (data.multi) multiIcon else icon
val icon = if (data.mini) miniIcon else if (data.multi) multiIcon else icon
val vd: Drawable = ContextCompat.getDrawable(context, icon)!!
DrawableCompat.setTint(vd, ContextCompat.getColor(context, data.tint));
DrawableCompat.setTintMode(vd, PorterDuff.Mode.MULTIPLY);
DrawableCompat.setTint(vd, ContextCompat.getColor(context, data.tint))
DrawableCompat.setTintMode(vd, PorterDuff.Mode.MULTIPLY)
val density = context.resources.displayMetrics.density
val width =
(height.toFloat() * density / vd.intrinsicHeight * vd.intrinsicWidth).roundToInt()
val height = (height * density).roundToInt()
val (markerWidth, markerHeight) = if (data.mini) {
vd.intrinsicWidth to vd.intrinsicHeight
} else {
(height.toFloat() * density / vd.intrinsicHeight * vd.intrinsicWidth).roundToInt() to
(height * density).roundToInt()
}
val (extraIconSize, extraIconShift) = if (data.mini) 0 to 0 else {
(0.75 * markerWidth).roundToInt() to (0.25 * markerWidth).roundToInt()
}
val leftPadding = width * (oversize - 1) / 2
val topPadding = height * (oversize - 1)
val totalWidth = markerWidth + 2 * extraIconShift
val totalHeight = markerHeight + extraIconShift
val (leftPadding, topPadding) = if (!data.mini) {
((totalWidth) * (oversize - 1) / 2).roundToInt() + extraIconShift to
((totalHeight) * (oversize - 1)).roundToInt() + extraIconShift
} else {
0 to 0
}
vd.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + width,
topPadding.toInt() + height
leftPadding, topPadding,
leftPadding + markerWidth,
topPadding + markerHeight
)
vd.alpha = data.alpha
val bm = Bitmap.createBitmap(
(width * oversize).toInt(), (height * oversize).toInt(),
(totalWidth * oversize).roundToInt(), (totalHeight * oversize).roundToInt(),
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bm)
val scale = data.scale.toFloat() / scaleResolution
canvas.scale(
scale,
scale,
leftPadding + width / 2f,
topPadding + height.toFloat()
)
val scale = data.scale.toFloat() / if (data.mini) scaleResolutionMini else scaleResolution
val (originX, originY) = if (data.mini) {
canvas.width / 2f to
canvas.height / 2f
} else {
canvas.width / 2f to
canvas.height.toFloat()
}
canvas.scale(scale, scale, originX, originY)
vd.draw(canvas)
if (data.highlight) {
val hIcon = if (data.multi) highlightIconMulti else highlightIcon
val hIcon =
if (data.mini) highlightIconMini else if (data.multi) highlightIconMulti else highlightIcon
val highlightDrawable = ContextCompat.getDrawable(context, hIcon)!!
highlightDrawable.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + width,
topPadding.toInt() + height
leftPadding, topPadding,
leftPadding + markerWidth,
topPadding + markerHeight
)
highlightDrawable.alpha = data.alpha
highlightDrawable.draw(canvas)
}
if (data.fault) {
if (data.fault && !data.mini) {
val faultDrawable = ContextCompat.getDrawable(context, faultIcon)!!
val faultSize = 0.75
val faultShift = 0.25
val base = width
faultDrawable.setBounds(
(leftPadding.toInt() + base * (1 - faultSize + faultShift)).toInt(),
(topPadding.toInt() - base * faultShift).toInt(),
(leftPadding.toInt() + base * (1 + faultShift)).toInt(),
(topPadding.toInt() + base * (faultSize - faultShift)).toInt()
leftPadding + markerWidth + extraIconShift - extraIconSize,
topPadding - extraIconShift,
leftPadding + markerWidth + extraIconShift,
topPadding + extraIconSize - extraIconShift
)
faultDrawable.alpha = data.alpha
faultDrawable.draw(canvas)
}
if (data.fav) {
if (data.fav && !data.mini) {
val favDrawable = ContextCompat.getDrawable(context, favIcon)!!
val favSize = 0.75
val favShiftY = 0.25
val favShiftX = if (data.fault) -0.5 else 0.25
val base = width
val favShiftY = extraIconShift
val favShiftX = if (data.fault) extraIconShift - extraIconSize else extraIconShift
favDrawable.setBounds(
(leftPadding.toInt() + base * (1 - favSize + favShiftX)).toInt(),
(topPadding.toInt() - base * favShiftY).toInt(),
(leftPadding.toInt() + base * (1 + favShiftX)).toInt(),
(topPadding.toInt() + base * (favSize - favShiftY)).toInt()
leftPadding + markerWidth - extraIconSize + favShiftX,
topPadding - favShiftY,
leftPadding + markerWidth + favShiftX,
topPadding + extraIconSize - favShiftY
)
favDrawable.alpha = data.alpha
favDrawable.draw(canvas)

View File

@@ -25,6 +25,10 @@ fun getMarkerTint(
}
}
val chargerZ = 1
val clusterZ = chargerZ + 1
val placeSearchZ = clusterZ + 1
class MarkerAnimator(val gen: ChargerIconGenerator) {
private val animatingMarkers = hashMapOf<Marker, ValueAnimator>()
@@ -34,7 +38,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
highlight: Boolean,
fault: Boolean,
multi: Boolean,
fav: Boolean
fav: Boolean,
mini: Boolean
) {
animatingMarkers[marker]?.let {
it.cancel()
@@ -53,7 +58,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
highlight = highlight,
fault = fault,
multi = multi,
fav = fav
fav = fav,
mini = mini
)
)
}
@@ -73,7 +79,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
highlight: Boolean,
fault: Boolean,
multi: Boolean,
fav: Boolean
fav: Boolean,
mini: Boolean
) {
animatingMarkers[marker]?.let {
it.cancel()
@@ -92,7 +99,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
highlight = highlight,
fault = fault,
multi = multi,
fav = fav
fav = fav,
mini = mini
)
)
}
@@ -116,7 +124,7 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
marker.remove()
}
fun animateMarkerBounce(marker: Marker) {
fun animateMarkerBounce(marker: Marker, mini: Boolean) {
animatingMarkers[marker]?.let {
it.cancel()
animatingMarkers.remove(marker)
@@ -127,7 +135,7 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
interpolator = BounceInterpolator()
addUpdateListener { state ->
val t = max(1f - state.animatedValue as Float, 0f) / 2
marker.setAnchor(0.5f, 1.0f + t)
marker.setAnchor(0.5f, (if (mini) 0.5f else 1.0f) + t)
}
addListener(onEnd = {
animatingMarkers.remove(marker)

View File

@@ -35,9 +35,7 @@ data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable
internal fun getClusterDistance(zoom: Float): Int? {
return when (zoom) {
in 0.0..7.0 -> 100
in 7.0..11.5 -> 75
in 11.5..12.5 -> 60
in 12.5..13.0 -> 45
in 7.0..11.0 -> 75
else -> null
}
}
@@ -119,7 +117,9 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
MediatorLiveData<Resource<List<ChargepointListItem>>>()
.apply {
value = Resource.loading(emptyList())
listOf(mapPosition, filtersWithValue, referenceData).forEach {
// this is not automatically updated with mapPosition, as we only want to update
// when map is idle.
listOf(filtersWithValue, referenceData).forEach {
addSource(it) {
reloadChargepoints()
}
@@ -270,15 +270,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
}
suspend fun copyFiltersToCustom() {
if (filterStatus.value == FILTERS_CUSTOM) return
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
filterValues.value?.map {
it.profile = FILTERS_CUSTOM
it
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
filterStatus.value?.let { db.filterValueDao().copyFiltersToCustom(it, prefs.dataSource) }
}
fun setMapType(type: AnyMap.Type) {
@@ -306,6 +298,29 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
chargepointLoader(Triple(pos, filters, referenceData))
}
private val miniMarkerThreshold = 13f
private val clusterThreshold = 11f
val useMiniMarkers: LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
for (source in listOf(filteredMinPower, mapPosition)) {
addSource(source) {
val minPower = filteredMinPower.value ?: 0
val zoom = mapPosition.value?.zoom
value = when {
zoom == null -> {
false
}
minPower >= 100 -> {
// when only showing high-power chargers we can use large markers
zoom < clusterThreshold
}
else -> {
zoom < miniMarkerThreshold
}
}
}
}
}.distinctUntilChanged()
private var chargepointLoader =
throttleLatest(
500L,
@@ -350,7 +365,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
if (connectorsVal.all) null else connectorsVal.values.map {
GEChargepoint.convertTypeFromGE(it)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
filteredMinPower.value = filters.getSliderValue("min_power")
} else if (api is OpenChargeMapApiWrapper) {
val connectorsVal = filters.getMultipleChoiceValue("connectors")!!
filteredConnectors.value =
@@ -360,7 +375,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
refData as OCMReferenceData
)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
filteredMinPower.value = filters.getSliderValue("min_power")
} else {
filteredConnectors.value = null
filteredMinPower.value = null

View File

@@ -0,0 +1,9 @@
<vector android:viewportHeight="24"
android:viewportWidth="24"
android:width="16dp"
android:height="16dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M12,12m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector android:viewportHeight="24"
android:viewportWidth="24"
android:width="16dp"
android:height="16dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#dddddd"
android:pathData="M12,12m-8.5,0a8.5,8.5 0,1 1,17 0a8.5,8.5 0,1 1,-17 0" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,12m-7.5,0a7.5,7.5 0,1 1,15 0a7.5,7.5 0,1 1,-15 0" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M1.79,12l5.58,5.59L5.96,19 0.37,13.41 1.79,12zM2.24,4.22L12.9,14.89l-1.28,1.28L7.44,12l-1.41,1.41L11.62,19l2.69,-2.69 4.89,4.89 1.41,-1.41L3.65,2.81 2.24,4.22zM17.14,13.49L23.62,7 22.2,5.59l-6.48,6.48 1.42,1.42zM17.96,7l-1.41,-1.41 -3.65,3.66 1.41,1.41L17.96,7z" />
</vector>

View File

@@ -0,0 +1,10 @@
Verbesserungen:
- Neue Mini-Marker zur Vermeidung von Clustering bei bestimmten Zoomstufen
- Livedaten für Ladestationen in Köln
- Android Auto: App startet direkt mit Anzeige der nahe gelegenen Ladestationen (Umschaltung zu Favoriten via Filtermenü)
- Android Auto: Möglichkeit zum Bearbeiten der Filtereinstellungen auf dem Fahrzeugbildschirm
Fehler behoben:
- Android Auto: Ladetarifauswahl funktionierte nicht
- Abstürze behoben
- Text auf Spendenseite in F-Droid-Version korrigiert

View File

@@ -13,4 +13,6 @@ Funktionen:
EVMap ist ein Open-Source-Projekt und unter https://github.com/johan12345/EVMap zu finden.
Die App ist kein offizielles Angebot von GoingElectric.de oder Open Charge Map, sondern nutzt die öffentlichen APIs dieser Seiten.
Die App ist kein offizielles Angebot von GoingElectric.de oder Open Charge Map, sondern nutzt die öffentlichen APIs dieser Seiten.
Eine Liste der benötigten Berechtigungen mit Beschreibung gibt es unter diesem Link: https://evmap.vonforst.net/de/permissions.html

View File

@@ -0,0 +1,8 @@
Improvements:
- New mini markers to avoid clustering at some zoom levels
- Android Auto: App launches directly with nearby chargers (switch to favorites now via filter menu)
- Android Auto: Ability to create and edit filter profiles on the car screen
Bugfixes:
- Android Auto: Selecting charging plans did not work
- Fixed crashes

View File

@@ -13,4 +13,6 @@ Features:
EVMap is an open source project and can be found at https://github.com/johan12345/EVMap.
This app is not an official product of GoingElectric.de or Open Charge Map, it only uses their public APIs.
This app is not an official product of GoingElectric.de or Open Charge Map, it only uses their public APIs.
A list of necessary permissions with explanations is available here: https://evmap.vonforst.net/en/permissions.html