Compare commits

...

19 Commits
0.4.2 ... 0.5.0

Author SHA1 Message Date
johan12345
0be90d8801 Release 0.5.0 2021-03-28 23:12:37 +02:00
johan12345
4ca9cc68cb Handle intents to https://www.goingelectric.de/stromtankstellen website 2021-03-28 23:02:24 +02:00
johan12345
62e9acf9be throttle repetitive loading of chargepoints to 500 ms 2021-03-28 22:43:08 +02:00
johan12345
6cb682f065 Preserve selected map type across app restarts 2021-03-28 21:46:59 +02:00
johan12345
4cfd5c8ef2 follow current location in map view (fixes #56) 2021-03-28 21:42:26 +02:00
johan12345
24bf66ddbe fix calculation of total chargers from filtered availability introduced in a0b0339c8b 2021-03-28 18:42:07 +02:00
johan12345
a0b0339c8b Handle geo intents to open map (fixes #69) 2021-03-27 21:35:42 +01:00
johan12345
2c9081b313 filter availability displayed in sparse view by selected connectors 2021-03-27 20:58:38 +01:00
johan12345
bd245801b0 refactoring of FilterValues using typealias and extension function 2021-03-27 20:48:15 +01:00
johan12345
11dac62b94 update copyright year 2021-03-24 08:43:25 +01:00
Johan von Forstner
a8bac7875a README.md: document Mapbox API key 2021-02-08 22:17:51 +01:00
johan12345
dbba00b51b Rework filter profile delete undo functionality (similar bug to #70) 2021-01-28 22:45:05 +01:00
johan12345
90cddce54c fix #70: Renaming filter profile resets settings 2021-01-28 21:47:47 +01:00
Johan von Forstner
f0f6c08610 Release 0.4.3 2021-01-17 14:15:46 +01:00
Johan von Forstner
a2fe9a06c5 fix another IllegalStateException 2021-01-17 14:09:37 +01:00
Johan von Forstner
cb79f17c23 catch IllegalArgumentException 2021-01-17 14:08:28 +01:00
Johan von Forstner
0009895537 fix IllegalStateException 2021-01-17 14:07:20 +01:00
Johan von Forstner
df705670b1 fix ClassCastException 2021-01-17 14:00:35 +01:00
Johan von Forstner
c616e9fdbd README.md: Describe map backends
see also #36
2021-01-06 19:30:45 +01:00
23 changed files with 408 additions and 105 deletions

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Johan von Forstner
Copyright (c) 2020-2021 Johan von Forstner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -20,6 +20,7 @@ Features
- Favorites list, also with availability information
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
Screenshots
-----------
@@ -31,10 +32,10 @@ Development setup
The App is developed using Android Studio.
For testing the app, you need to obtain API Keys for the
For testing the app, you need to obtain free API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/)
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated). These APIs need to be put into the
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These APIs need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
@@ -43,6 +44,9 @@ following content:
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="mapbox_key" translatable="false">
insert your Mapbox key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 30
versionCode 34
versionName "0.4.2"
versionCode 39
versionName "0.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -120,7 +120,7 @@ dependencies {
implementation 'com.github.pengrad:mapscaleview:1.6.0'
// AnyMaps
def anyMapsVersion = '7753eeb7b0'
def anyMapsVersion = '1f050d860f'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"

View File

@@ -38,6 +38,21 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data
android:scheme="https"
android:host="www.goingelectric.de"
android:pathPrefix="/stromtankstellen/" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
</application>

View File

@@ -17,6 +17,7 @@ import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.fragment.MapFragment
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
@@ -64,6 +65,34 @@ class MapsActivity : AppCompatActivity() {
prefs = PreferenceDataSource(this)
checkPlayServices(this)
if (intent?.scheme == "geo") {
val pos = intent.data?.schemeSpecificPart?.split("?")?.get(0)
val coords = pos?.split(",")?.map { it.toDoubleOrNull() }
if (coords != null && coords.size == 2) {
val lat = coords[0]
val lon = coords[1]
if (lat != null && lon != null) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocation(lat, lon))
.createPendingIntent()
deepLink.send()
}
}
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showChargerById(id))
.createPendingIntent()
deepLink.send()
}
}
}
fun navigateTo(charger: ChargeLocation) {

View File

@@ -8,7 +8,10 @@ import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.FilterValues
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getMultipleChoiceValue
import net.vonforst.evmap.viewmodel.getSliderValue
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -113,7 +116,21 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
)
) {
fun applyFilters(filters: FilterValues?): ChargeLocationStatus {
if (filters == null) return this
val connectorsVal = filters.getMultipleChoiceValue("connectors")
val minPower = filters.getSliderValue("min_power")
val statusFiltered = status.filterKeys {
(connectorsVal.all || it.type in connectorsVal.values) && it.power > minPower
}
return this.copy(status = statusFiltered)
}
val totalChargepoints = status.map { it.key.count }.sum()
}
enum class ChargepointStatus {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED

View File

@@ -86,8 +86,9 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
override fun onConnected() {
val context = this.context ?: return
if (ContextCompat.checkSelfPermission(
requireContext(),
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {

View File

@@ -33,6 +33,8 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
class FilterProfilesFragment : Fragment() {
private lateinit var touchHelper: ItemTouchHelper
private lateinit var adapter: FilterProfilesAdapter
private lateinit var binding: FragmentFilterProfilesBinding
private val vm: FilterProfilesViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -40,6 +42,7 @@ class FilterProfilesFragment : Fragment() {
}
})
private var deleteSnackbar: Snackbar? = null
private var toDelete: FilterProfile? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -64,7 +67,7 @@ class FilterProfilesFragment : Fragment() {
)
val touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
) {
@@ -173,7 +176,7 @@ class FilterProfilesFragment : Fragment() {
}
})
val adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
adapter = FilterProfilesAdapter(touchHelper, onDelete = { fp ->
delete(fp)
}, onRename = { fp ->
showEditTextDialog(requireContext()) { dialog, input ->
@@ -183,7 +186,7 @@ class FilterProfilesFragment : Fragment() {
.setMessage(R.string.save_profile_enter_name)
.setPositiveButton(R.string.ok) { di, button ->
lifecycleScope.launch {
vm.insert(fp.copy(name = input.text.toString()))
vm.update(fp.copy(name = input.text.toString()))
}
}
.setNegativeButton(R.string.cancel) { di, button ->
@@ -192,7 +195,7 @@ class FilterProfilesFragment : Fragment() {
}
})
binding.filterProfilesList.apply {
this.adapter = adapter
this.adapter = this@FilterProfilesFragment.adapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -210,19 +213,43 @@ class FilterProfilesFragment : Fragment() {
}
fun delete(fp: FilterProfile) {
vm.delete(fp.id)
val position = vm.filterProfiles.value?.indexOf(fp) ?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fp
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fp.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
vm.insert(fp.copy(id = 0))
}
toDelete = null
adapter.notifyItemChanged(position)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
actuallyDelete()
}
}
})
deleteSnackbar = snackbar
snackbar.show()
} ?: run {
actuallyDelete()
}
}
private fun actuallyDelete() {
toDelete?.let { vm.delete(it.id) }
toDelete = null
}
override fun onStop() {
super.onStop()
actuallyDelete()
}
}

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Location
import android.os.Bundle
import android.os.Handler
import android.view.*
@@ -50,6 +51,8 @@ import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.mapzen.android.lost.api.LocationListener
import com.mapzen.android.lost.api.LocationRequest
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import io.michaelrocks.bimap.HashBiMap
@@ -74,13 +77,14 @@ import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.*
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks {
LostApiClient.ConnectionCallbacks, LocationListener {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -94,6 +98,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationClient: LostApiClient
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
@@ -224,7 +229,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
if (!PreferenceDataSource(requireContext()).welcomeDialogShown) {
navController.navigate(R.id.action_map_to_welcome)
try {
navController.navigate(R.id.action_map_to_welcome)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
}
}
@@ -233,6 +242,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
vm.reloadPrefs()
if (requestingLocationUpdates && ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
requestLocationUpdates()
}
}
private fun setupClickListeners() {
@@ -270,7 +286,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
(activity as? MapsActivity)?.openUrl(
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric"
)
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
@@ -353,7 +370,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.bottomSheetState.value = newState
updateBackPressedCallback()
if (vm.layersMenuOpen.value!! && newState !in listOf(BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING, BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN, BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED)) {
if (vm.layersMenuOpen.value!! && newState !in listOf(
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING,
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN,
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
)
) {
closeLayersMenu()
}
}
@@ -673,6 +695,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.setOnCameraMoveListener {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnCameraMoveStartedListener { reason ->
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE
&& vm.myLocationEnabled.value == true
) {
// disable location following when manually scrolling the map
vm.myLocationEnabled.value = false
removeLocationUpdates()
}
}
map.setOnMarkerClickListener { marker ->
when (marker) {
in markers -> {
@@ -698,6 +729,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
backPressedCallback.handleOnBackPressed()
}
}
map.setMapType(vm.mapType.value)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
@@ -711,33 +743,57 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val position = vm.mapPosition.value
val lat = arguments?.optDouble(ARG_LAT)
val lon = arguments?.optDouble(ARG_LON)
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
var positionSet = false
if (position != null) {
val cameraUpdate =
map.cameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
positionSet = true
} else if (chargerId != null && (lat == null || lon == null)) {
// show given charger ID
vm.loadChargerById(chargerId)
vm.chargerSparse.observe(
viewLifecycleOwner,
object : Observer<ChargeLocation> {
override fun onChanged(item: ChargeLocation?) {
if (item?.id == chargerId) {
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(
LatLng(item.coordinates.lat, item.coordinates.lng), 16f
)
map.moveCamera(cameraUpdate)
vm.chargerSparse.removeObserver(this)
}
}
})
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
map.moveCamera(cameraUpdate)
// show charger detail after chargers were loaded
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
if (chargerId != null) {
// show charger detail after chargers were loaded
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
}
}
}
}
})
})
} else {
// mark location as search result
vm.searchResult.value = PlaceWithBounds(LatLng(lat, lon), null)
}
positionSet = true
}
@@ -765,15 +821,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
val map = this.map ?: return
map.setMyLocationEnabled(true)
vm.myLocationEnabled.value = true
map.uiSettings.setMyLocationButtonEnabled(false)
if (moveTo && locationClient.isConnected) {
moveToCurrentLocation(map, animate)
if (moveTo) {
vm.myLocationEnabled.value = true
if (locationClient.isConnected) {
moveToLastLocation(map, animate)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun moveToCurrentLocation(map: AnyMap, animate: Boolean) {
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
@@ -1050,21 +1109,64 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
putDouble(ARG_LON, charger.coordinates.lng)
}
}
}
override fun onConnected() {
val map = this.map ?: return
if (vm.myLocationEnabled.value == true) {
if (ActivityCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
moveToCurrentLocation(map, false)
fun showLocation(lat: Double, lon: Double): Bundle {
return Bundle().apply {
putDouble(ARG_LAT, lat)
putDouble(ARG_LON, lon)
}
}
fun showChargerById(id: Long): Bundle? {
return Bundle().apply {
putLong(ARG_CHARGER_ID, id)
}
}
}
override fun onConnected() {
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (ActivityCompat.checkSelfPermission(
context,
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
moveToLastLocation(map, false)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
private fun requestLocationUpdates() {
val request: LocationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.setInterval(5000)
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
requestingLocationUpdates = true
}
private fun removeLocationUpdates() {
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
}
override fun onConnectionSuspended() {
}
override fun onLocationChanged(location: Location?) {
val map = this.map ?: return
if (location == null || vm.myLocationEnabled.value == false) return
val latLng = LatLng(location.latitude, location.longitude)
vm.location.value = latLng
val camUpdate = map.cameraUpdateFactory.newLatLng(latLng)
map.animateCamera(camUpdate)
}
override fun onPause() {
super.onPause()
removeLocationUpdates()
}
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.FILTERS_DISABLED
@@ -73,6 +74,12 @@ class PreferenceDataSource(val context: Context) {
context.getString(R.string.pref_map_provider_default)
)!!
var mapType: AnyMap.Type
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
set(type) {
sp.edit().putString("map_type", type.toString()).apply()
}
var welcomeDialogShown: Boolean
get() = sp.getBoolean("welcome_dialog_shown", false)
set(value) {

View File

@@ -163,8 +163,8 @@ fun setLinkify(textView: TextView, oldValue: Int, newValue: Int) {
textView.linksClickable = newValue != 0
// remove spans
if (newValue == 0) {
val text = textView.text as SpannableString
val text = textView.text
if (newValue == 0 && text != null && text is SpannableString) {
text.getSpans(0, text.length, Any::class.java).forEach {
text.removeSpan(it)
}

View File

@@ -33,6 +33,12 @@ class FilterProfilesViewModel(application: Application) : AndroidViewModel(appli
}
}
fun update(item: FilterProfile) {
viewModelScope.launch {
db.filterProfileDao().update(item)
}
}
fun reorderProfiles(list: List<FilterProfile>) {
viewModelScope.launch {
db.filterProfileDao().update(*list.toTypedArray())

View File

@@ -135,8 +135,8 @@ private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<List<FilterWithValue<out FilterValue>>> =
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
@@ -173,7 +173,7 @@ class FilterViewModel(application: Application, geApiKey: String) :
db.filterValueDao().getFilterValues(FILTERS_CUSTOM)
}
val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
}
@@ -331,5 +331,19 @@ data class SliderFilterValue(
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }!!.value as BooleanFilterValue).value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }!!.value as SliderFilterValue).value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }!!.filter as MultipleChoiceFilter
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L

View File

@@ -6,7 +6,6 @@ import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
@@ -37,7 +36,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var chargepointLoader: Job? = null
val bottomSheetState: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
@@ -69,7 +67,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
private val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
}
@@ -147,7 +145,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val callback = { _: Any? ->
val loc = location.value
val charger = chargerSparse.value
value = if (loc != null && charger != null && myLocationEnabled.value == true) {
value = if (loc != null && charger != null) {
distanceBetween(
loc.latitude,
loc.longitude,
@@ -158,7 +156,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
addSource(chargerSparse, callback)
addSource(location, callback)
addSource(myLocationEnabled, callback)
}
}
val location: MutableLiveData<LatLng> by lazy {
@@ -177,6 +174,21 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
}
val filteredAvailability: MediatorLiveData<Resource<ChargeLocationStatus>> by lazy {
MediatorLiveData<Resource<ChargeLocationStatus>>().apply {
val callback = { _: Any? ->
val av = availability.value
val filters = filtersWithValue.value
if (av?.status == Status.SUCCESS && filters != null) {
value = Resource.success(av.data!!.applyFilters(filters))
} else {
value = av
}
}
addSource(availability, callback)
addSource(filtersWithValue, callback)
}
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
@@ -196,7 +208,10 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val mapType: MutableLiveData<AnyMap.Type> by lazy {
MutableLiveData<AnyMap.Type>().apply {
value = AnyMap.Type.NORMAL
value = prefs.mapType
observeForever {
prefs.mapType = it
}
}
}
@@ -260,39 +275,41 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
loadChargepoints(pos, filters)
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: List<FilterWithValue<out FilterValue>>
) {
chargepointLoader?.cancel()
private var chargepointLoader =
throttleLatest(500L, viewModelScope) { data: Pair<MapPosition, FilterValues> ->
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredChargeCards.value = null
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredChargeCards.value = null
val bounds = mapPosition.bounds
val zoom = mapPosition.zoom
chargepointLoader = viewModelScope.launch {
val result = getChargepointsWithFilters(bounds, zoom, filters)
val mapPosition = data.first
val filters = data.second
val result = getChargepointsWithFilters(mapPosition.bounds, mapPosition.zoom, filters)
filteredConnectors.value = result.second
filteredChargeCards.value = result.third
chargepoints.value = result.first
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: FilterValues
) {
chargepointLoader(Pair(mapPosition, filters))
}
private suspend fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: List<FilterWithValue<out FilterValue>>
filters: FilterValues
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
val freecharging = getBooleanValue(filters, "freecharging")
val freeparking = getBooleanValue(filters, "freeparking")
val open247 = getBooleanValue(filters, "open_247")
val barrierfree = getBooleanValue(filters, "barrierfree")
val excludeFaults = getBooleanValue(filters, "exclude_faults")
val minPower = getSliderValue(filters, "min_power")
val minConnectors = getSliderValue(filters, "min_connectors")
val freecharging = filters.getBooleanValue("freecharging")
val freeparking = filters.getBooleanValue("freeparking")
val open247 = filters.getBooleanValue("open_247")
val barrierfree = filters.getBooleanValue("barrierfree")
val excludeFaults = filters.getBooleanValue("exclude_faults")
val minPower = filters.getSliderValue("min_power")
val minConnectors = filters.getSliderValue("min_connectors")
val connectorsVal = getMultipleChoiceValue(filters, "connectors")
val connectorsVal = filters.getMultipleChoiceValue("connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Triple(Resource.success(emptyList()), null, null)
@@ -300,7 +317,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val connectors = formatMultipleChoice(connectorsVal)
val filteredConnectors = if (connectorsVal.all) null else connectorsVal.values
val chargeCardsVal = getMultipleChoiceValue(filters, "chargecards")
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Triple(Resource.success(emptyList()), filteredConnectors, null)
@@ -309,14 +326,14 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val filteredChargeCards =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val networksVal = getMultipleChoiceValue(filters, "networks")
val networksVal = filters.getMultipleChoiceValue("networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = getMultipleChoiceValue(filters, "categories")
val categoriesVal = filters.getMultipleChoiceValue("categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
@@ -398,26 +415,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private fun getBooleanValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as BooleanFilterValue).value
private fun getSliderValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as SliderFilterValue).value
private fun getMultipleChoiceFilter(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.filter as MultipleChoiceFilter
private fun getMultipleChoiceValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)
@@ -445,4 +442,32 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
})
}
fun loadChargerById(chargerId: Long) {
chargerDetails.value = Resource.loading(null)
chargerSparse.value = null
api.getChargepointDetail(chargerId).enqueue(object :
Callback<ChargepointList> {
override fun onFailure(call: Call<ChargepointList>, t: Throwable) {
chargerSparse.value = null
chargerDetails.value = Resource.error(t.message, null)
t.printStackTrace()
}
override fun onResponse(
call: Call<ChargepointList>,
response: Response<ChargepointList>
) {
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerDetails.value = Resource.error(response.message(), null)
chargerSparse.value = null
} else {
val charger = response.body()!!.chargelocations[0] as ChargeLocation
chargerDetails.value =
Resource.success(charger)
chargerSparse.value = charger
}
}
})
}
}

View File

@@ -3,6 +3,10 @@ package net.vonforst.evmap.viewmodel
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
@@ -63,4 +67,27 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
fun call() {
value = null
}
}
fun <T> throttleLatest(
skipMs: Long = 300L,
coroutineScope: CoroutineScope,
destinationFunction: suspend (T) -> Unit
): (T) -> Unit {
var throttleJob: Job? = null
var waitingParam: T? = null
return { param: T ->
if (throttleJob?.isCompleted != false) {
throttleJob = coroutineScope.launch {
destinationFunction(param)
delay(skipMs)
waitingParam?.let { wParam ->
waitingParam = null
destinationFunction(wParam)
}
}
} else {
waitingParam = param
}
}
}

View File

@@ -35,6 +35,10 @@
name="availability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="filteredAvailability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="chargeCards"
type="java.util.Map&lt;Long, ChargeCard&gt;" />
@@ -116,7 +120,7 @@
android:gravity="end"
android:maxLines="1"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(availability.data.status.values())), charger.data.totalChargepoints)}"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(BindingAdaptersKt.flatten(filteredAvailability.data.status.values())), filteredAvailability.data.totalChargepoints)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(availability.data.status.values())}"

View File

@@ -139,6 +139,7 @@
layout="@layout/detail_view"
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"

View File

@@ -37,7 +37,7 @@
<string name="settings_ui">Oberfläche</string>
<string name="settings_map">Karte</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="copyright_summary">©20202021 Johan von Forstner</string>
<string name="other">Sonstiges</string>
<string name="privacy">Datenschutzerklärung</string>
<string name="fav_add">Zu Favoriten hinzufügen</string>

View File

@@ -36,7 +36,7 @@
<string name="settings_ui">User Interface</string>
<string name="settings_map">Map</string>
<string name="copyright">Copyright</string>
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="copyright_summary">©20202021 Johan von Forstner</string>
<string name="other">Other</string>
<string name="privacy">Privacy Notice</string>
<string name="fav_add">Add to favorites</string>

View File

@@ -0,0 +1,2 @@
Verbesserungen:
- Verschiedene Abstürze behoben

View File

@@ -0,0 +1,10 @@
Neue Features:
- Kartenausschnitt folgt der aktuellen Position
- Verfügbarkeitsanzeige in der Kartenansicht beinhaltet nur die per Filter gewählten Anschlüsse (z.B. nur CCS)
- Links zu https://www.goingelectric.de/stromtankstellen können in EVMap geöffnet werden
- Geteilte Standorte (z.B. aus Messenger-Apps) können in EVMap geöffnet werden
Fehlerkorrekturen:
- Filtereinstellungen wurden bei Umbenennen eines Filterprofils fälschlicherweise gelöscht
- Ausgewählter Kartentyp (Satellit, Gelände, Standard) bleibt beim App-Neustart erhalten
- Copyright-Jahr aktualisiert

View File

@@ -0,0 +1,2 @@
Improvements:
- Fixed various crashes

View File

@@ -0,0 +1,10 @@
New Features:
- Map follows current location
- Availability indicator in map view only shows currently filtered connectors (e.g. only CCS)
- Links to https://www.goingelectric.de/stromtankstellen can be opened in EVMap
- Shared locations (e.g. from messenger apps) can be opened in EVMap
Bugfixes:
- Filter settings would be deleted when renaming a saved filter profile
- Selected map type (Default, Satellite, Terrain) will be kept across app restarts
- Updated copyright year