Compare commits

...

64 Commits
0.0.4 ... 0.1.2

Author SHA1 Message Date
johan12345
2a6ac0ac1b Release 0.1.2 2020-05-27 21:08:10 +02:00
johan12345
8673efd1cd favorites view: limit length of text fields 2020-05-27 21:05:37 +02:00
johan12345
ae40b8c634 show fault reports (fixes #2) 2020-05-27 21:03:46 +02:00
johan12345
0cdb12711d do not show opening hours if they are not available 2020-05-27 20:14:49 +02:00
johan12345
69ccc55ad4 move Chargeprice.app button below connectors (#12) 2020-05-27 20:10:37 +02:00
johan12345
304f46e189 fix hiding and showing of layers FAB and menu when detail view is openend 2020-05-26 23:33:01 +02:00
johan12345
01f06621f4 add link to chargeprice.app to compare prices (#12) 2020-05-26 23:09:48 +02:00
Johan von Forstner
f986a68db8 update version code 2020-05-24 16:54:12 +02:00
Johan von Forstner
441e78d807 Release 0.1.1 2020-05-24 16:52:16 +02:00
Johan von Forstner
6481d651a0 add way to quickly enable and disable filters (first step towards #16) 2020-05-24 16:51:18 +02:00
Johan von Forstner
9a7db8997a Add link from coordinates to maps app (fixes #17) 2020-05-24 16:10:33 +02:00
Johan von Forstner
d94053261c remove debugging println call 2020-05-24 15:38:21 +02:00
Johan von Forstner
39dc50724e add FAQ page with legend for marker colors (fixes #21) 2020-05-24 11:54:50 +02:00
Johan von Forstner
34fe126fd0 add option to show Google Maps traffic layer (fixes #19) 2020-05-24 11:26:13 +02:00
Johan von Forstner
1f81a11ad1 add map type chooser 2020-05-24 09:53:56 +02:00
Johan von Forstner
74b74dcd07 add marker for selected search result (fixes #18) 2020-05-24 08:16:04 +02:00
Johan von Forstner
ec623c9396 make clustering more dynamic (fixes #14) 2020-05-23 19:51:44 +02:00
Johan von Forstner
c10c59e3b1 fix lint error 2020-05-22 09:04:23 +02:00
Johan von Forstner
2bd5f746ed Release 0.1.0 2020-05-21 16:46:36 +02:00
Johan von Forstner
fbc15f2925 sort donations by price 2020-05-21 16:45:54 +02:00
Johan von Forstner
11f492df1d Release 0.0.7 2020-05-21 15:11:22 +02:00
Johan von Forstner
629fbb0f1b reduce clusterDistance to 40 2020-05-21 14:58:02 +02:00
Johan von Forstner
d00840c3bd implement donation view 2020-05-21 14:53:30 +02:00
Johan von Forstner
084084c26c fix highlighted charger after moving map 2020-05-19 20:50:54 +02:00
Johan von Forstner
f4b174efe1 bounce marker when selected 2020-05-19 20:46:34 +02:00
Johan von Forstner
81d3ba115a change package name and launcher name for debug version of the app 2020-05-19 20:42:42 +02:00
Johan von Forstner
a35a5f7050 re-add errorneously removed imports 2020-05-19 20:32:45 +02:00
Johan von Forstner
c1cec8781b highlight currently selected chharger (fixes #15) 2020-05-19 20:23:59 +02:00
Johan von Forstner
be98e7e266 Release 0.0.6 2020-05-17 22:40:02 +02:00
Johan von Forstner
49ef661ac1 dark mode: set text color for marker clustering to white 2020-05-17 22:39:12 +02:00
Johan von Forstner
1d98264437 fix color of location button in dark mode 2020-05-17 22:34:29 +02:00
Johan von Forstner
4d137614d5 fix broken database transactions 2020-05-17 22:33:04 +02:00
Johan von Forstner
0bb88c983e Release 0.0.5 2020-05-17 19:25:41 +02:00
Johan von Forstner
d460c34219 disable donations for now 2020-05-17 19:24:24 +02:00
Johan von Forstner
e91b7d26f8 add DonateFragment 2020-05-17 19:23:02 +02:00
Johan von Forstner
12e41bc38f make sure that app does not freeze waiting for picture to load 2020-05-17 14:25:01 +02:00
Johan von Forstner
ea94f67187 add badge showing how many filters are active 2020-05-17 14:17:41 +02:00
Johan von Forstner
9ad2f86b39 plugs filter: return empty list if none chosen (#9) 2020-05-17 13:46:03 +02:00
Johan von Forstner
d71e781c26 multiple choice filter: disable unnecessary buttons (#9) 2020-05-17 13:35:14 +02:00
Johan von Forstner
03410a4c49 show charger status "unknown" as question mark (fixes #7) 2020-05-17 13:21:19 +02:00
Johan von Forstner
3488e89dbc add link from favorites to detail view (fixes #8) 2020-05-16 17:27:29 +02:00
Johan von Forstner
ddbc63ae2a add missing animation file 2020-05-16 17:26:31 +02:00
Johan von Forstner
ee78ca31fe fix race condition when loading chargepoints on app start 2020-05-16 17:06:39 +02:00
Johan von Forstner
f79bd78a5d add empty state for favorites list 2020-05-16 16:34:55 +02:00
Johan von Forstner
374402c43a multiple choice filter: add "show more" button 2020-05-15 19:19:07 +02:00
Johan von Forstner
c82e12bb47 multiple choice filter: add "all" and "none" buttons 2020-05-15 18:56:20 +02:00
Johan von Forstner
02d24a3b3f wait to save filters before closing filter view 2020-05-15 18:52:23 +02:00
Johan von Forstner
4031c8f142 minimum number of chargepoints filter improvements (#9)
- do not use clustering if it needs to be applied
- fix combination with plug type filter
2020-05-15 18:37:17 +02:00
Johan von Forstner
d0851be528 make all address fields nullable 2020-05-15 18:30:56 +02:00
Johan von Forstner
2bd57f85d8 set map padding so that compass is not obstructed by toolbar 2020-05-14 18:47:41 +02:00
Johan von Forstner
eccd29b368 fix crash when NewMotion does not know the EVSEID 2020-05-14 18:41:31 +02:00
Johan von Forstner
4b1a3c424f make plugs filter use the plug list from GE API 2020-05-14 18:35:54 +02:00
Johan von Forstner
391bb094e0 update gradle plugin 2020-05-14 18:35:29 +02:00
Johan von Forstner
d6d5ab05a9 convert filters into LiveData in preparation for loading plug types from API 2020-05-11 20:01:05 +02:00
Johan von Forstner
be29316329 implement filter by connector type (#9) 2020-05-10 20:23:10 +02:00
Johan von Forstner
513d3ce4fb fix details showing links where they shouldn't 2020-05-10 20:19:06 +02:00
Johan von Forstner
7da49b256e minimum power filter: introduce steps 2020-05-10 18:20:09 +02:00
Johan von Forstner
e4932f2e4c update navigation library 2020-05-10 14:07:35 +02:00
Johan von Forstner
5c25ba1eca Update CustomBottomSheetBehavior library
add background behind toolbar buttons
2020-05-09 23:15:52 +02:00
Johan von Forstner
09880a58f4 Update CustomBottomSheetBehavior library 2020-05-09 22:53:48 +02:00
Johan von Forstner
cb6abe53fc add filter by minimum number of connectors (#9) 2020-05-09 12:48:28 +02:00
Johan von Forstner
22744da54b make postcode nullable 2020-05-09 12:47:52 +02:00
Johan von Forstner
8fabfd6aa6 set up OkHttp caching for GoingElectricApi 2020-05-07 20:01:47 +02:00
Johan von Forstner
e44903ff3b Travis CI: only deploy tagged commits 2020-05-07 08:27:44 +02:00
59 changed files with 6054 additions and 274 deletions

View File

@@ -28,4 +28,5 @@ deploy:
file: app/build/outputs/apk/release/app-release.apk
on:
repo: johan12345/EVMap
tags: true
skip_cleanup: 'true'

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 4
versionName "0.0.4"
versionCode 10
versionName "0.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -29,6 +29,10 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
def isRunningOnTravis = System.getenv("CI") == "true"
@@ -79,11 +83,12 @@ dependencies {
implementation 'androidx.core:core:1.3.0-rc01'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.1.0'
implementation 'com.google.android.material:material:1.2.0-alpha06'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.browser:browser:1.2.0'
implementation 'com.google.maps.android:android-maps-utils:0.5'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:73dd449f6f'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.google.android.gms:play-services-maps:17.0.0'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation 'com.google.android.libraries.places:places:2.2.0'
@@ -94,9 +99,11 @@ dependencies {
implementation 'com.github.MikeOrtiz:TouchImageView:2.3.3'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks:bimap:1.0.2'
// navigation library
def nav_version = "2.3.0-alpha05"
def nav_version = "2.3.0-alpha06"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -111,6 +118,11 @@ dependencies {
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "2.2.0"
implementation "com.android.billingclient:billing:$billing_version"
implementation "com.android.billingclient:billing-ktx:$billing_version"
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EV Map (debug)</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EV Map (debug)</string>
</resources>

View File

@@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.findNavController
@@ -48,33 +50,39 @@ class MapsActivity : AppCompatActivity() {
}
fun navigateTo(charger: ChargeLocation) {
val intent = Intent(Intent.ACTION_VIEW)
val coord = charger.coordinates
// google maps navigation
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
val pm = packageManager
if (intent.resolveActivity(pm) != null && prefs.navigateUseMaps) {
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent);
} else {
// fallback: generic geo intent
intent.data = Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
if (intent.resolveActivity(pm) != null) {
startActivity(intent);
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
)
}
showLocation(charger)
}
}
fun showLocation(charger: ChargeLocation) {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent);
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
)
}
}
fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
val intent = CustomTabsIntent.Builder()
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
.build()
intent.launchUrl(this, Uri.parse(url))
}
fun shareUrl(url: String) {

View File

@@ -0,0 +1,17 @@
package net.vonforst.evmap
import android.os.Bundle
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
val dbl = this.getDouble(name, Double.NaN)
return if (dbl.isNaN()) null else dbl
}
fun Bundle.optLong(name: String): Long? {
if (!this.containsKey(name)) return null
val lng = this.getLong(name, Long.MIN_VALUE)
return if (lng == Long.MIN_VALUE) null else lng
}

View File

@@ -2,20 +2,27 @@ package net.vonforst.evmap.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import androidx.core.view.children
import androidx.databinding.DataBindingUtil
import androidx.databinding.Observable
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceBinding
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.viewmodel.*
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
interface Equatable {
override fun equals(other: Any?): Boolean;
@@ -24,6 +31,8 @@ interface Equatable {
abstract class DataBindingAdapter<T : Equatable>() :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback()) {
var onClickListener: ((T) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
val layoutInflater = LayoutInflater.from(parent.context)
val binding =
@@ -41,6 +50,10 @@ abstract class DataBindingAdapter<T : Equatable>() :
open fun bind(holder: ViewHolder<T>, item: T) {
holder.binding.setVariable(BR.item, item)
holder.binding.executePendingBindings()
holder.binding.root.setOnClickListener {
val listener = onClickListener ?: return@setOnClickListener
listener(item)
}
}
class DiffCallback<T : Equatable> : DiffUtil.ItemCallback<T>() {
@@ -55,14 +68,16 @@ fun chargepointWithAvailability(
availability: Map<Chargepoint, List<ChargepointStatus>>?
): List<ConnectorAdapter.ChargepointWithAvailability>? {
return chargepoints?.map {
ConnectorAdapter.ChargepointWithAvailability(
it, availability?.get(it)?.count { it == ChargepointStatus.AVAILABLE }
)
val status = availability?.get(it)
ConnectorAdapter.ChargepointWithAvailability(it, status)
}
}
class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvailability>() {
data class ChargepointWithAvailability(val chargepoint: Chargepoint, val available: Int?) :
data class ChargepointWithAvailability(
val chargepoint: Chargepoint,
val status: List<ChargepointStatus>?
) :
Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_connector
@@ -74,7 +89,8 @@ class DetailAdapter : DataBindingAdapter<DetailAdapter.Detail>() {
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true
val links: Boolean = true,
val clickable: Boolean = false
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_detail
@@ -100,8 +116,20 @@ fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail>
R.string.network,
loc.network
) else null,
if (loc.faultReport != null) DetailAdapter.Detail(
R.drawable.ic_fault_report,
R.string.fault_report,
loc.faultReport.created?.let {
ctx.getString(R.string.fault_report_date,
loc.faultReport.created
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)))
} ?: "",
loc.faultReport.description ?: "",
clickable = true
) else null,
// TODO: separate layout for opening hours with expandable details
if (loc.openinghours != null) DetailAdapter.Detail(
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
loc.openinghours.getStatusText(ctx),
@@ -119,7 +147,8 @@ fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail>
R.string.coordinates,
loc.coordinates.formatDMS(),
loc.coordinates.formatDecimal(),
false
links = false,
clickable = true
)
)
}
@@ -146,7 +175,7 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
override fun getItemViewType(position: Int): Int = when (getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> R.layout.item_filter_multiple_choice
is SliderFilter -> R.layout.item_filter_slider
}
@@ -157,29 +186,125 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
super.bind(holder, item)
when (item.value) {
is SliderFilterValue -> {
val binding = holder.binding as ItemFilterSliderBinding
binding.progress = item.value.value
binding.seekBar.setOnSeekBarChangeListener(object :
SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar,
progress: Int,
fromUser: Boolean
) {
item.value.value = progress
binding.progress = progress
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
})
setupSlider(
holder.binding as ItemFilterSliderBinding,
item.filter as SliderFilter, item.value
)
}
is MultipleChoiceFilterValue -> {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
item.filter as MultipleChoiceFilter, item.value
)
}
}
}
private fun setupMultipleChoice(
binding: ItemFilterMultipleChoiceBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
val inflater = LayoutInflater.from(binding.root.context)
value.values.toList().forEach {
// delete values that cannot be selected anymore
if (it !in filter.choices.keys) value.values.remove(it)
}
fun updateButtons() {
value.all = value.values == filter.choices.keys
binding.btnAll.isEnabled = !value.all
binding.btnNone.isEnabled = value.values.isNotEmpty()
}
val chips = mutableMapOf<String, Chip>()
binding.chipGroup.children.forEach {
if (it.id != R.id.chipMore) binding.chipGroup.removeView(it)
}
filter.choices.entries.sortedByDescending {
it.key in value.values
}.sortedByDescending {
if (filter.commonChoices != null) it.key in filter.commonChoices else false
}.forEach { choice ->
val chip = inflater.inflate(
R.layout.item_filter_multiple_choice_chip,
binding.chipGroup,
false
) as Chip
chip.text = choice.value
chip.isChecked = choice.key in value.values || value.all
if (value.all && choice.key !in value.values) value.values.add(choice.key)
chip.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
value.values.add(choice.key)
} else {
value.values.remove(choice.key)
}
updateButtons()
}
if (filter.commonChoices != null && choice.key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
binding.chipGroup.addView(chip, binding.chipGroup.childCount - 1)
chips[choice.key] = chip
}
binding.btnAll.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = true }
updateButtons()
}
binding.btnNone.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = false }
updateButtons()
}
binding.chipMore.setOnClickListener {
binding.showingAll = !binding.showingAll
chips.forEach { (key, chip) ->
if (filter.commonChoices != null && key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
}
}
updateButtons()
}
private fun setupSlider(
binding: ItemFilterSliderBinding,
filter: SliderFilter,
value: SliderFilterValue
) {
binding.progress = filter.inverseMapping(value.value)
binding.mappedValue = value.value
binding.addOnPropertyChangedCallback(object :
Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
when (propertyId) {
BR.progress -> {
val mapped = filter.mapping(binding.progress)
value.value = mapped
binding.mappedValue = mapped
}
}
}
})
}
override fun getItemId(position: Int): Long {
val key = getItem(position).filter.key
var value = itemids[key]
@@ -190,4 +315,8 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
}
return value
}
}
class DonationAdapter() : DataBindingAdapter<DonationItem>() {
override fun getItemViewType(position: Int): Int = R.layout.item_donation
}

View File

@@ -40,7 +40,7 @@ interface NewMotionApi {
)
@JsonClass(generateAdapter = true)
data class NMEvse(val evseId: String, val status: String, val connectors: List<NMConnector>)
data class NMEvse(val evseId: String?, val status: String, val connectors: List<NMConnector>)
@JsonClass(generateAdapter = true)
data class NMConnector(

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.api.goingelectric
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.time.Instant
import java.time.LocalTime
@@ -65,7 +66,8 @@ internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
}
internal class JsonObjectOrFalseAdapter<T> private constructor(
private val objectDelegate: JsonAdapter<T>?
private val objectDelegate: JsonAdapter<T>,
private val clazz: Class<*>
) : JsonAdapter<T>() {
class Factory() : JsonAdapter.Factory {
@@ -80,27 +82,32 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
)) {
false -> null
true -> JsonObjectOrFalseAdapter(
moshi.adapter(clazz)
moshi.adapter(clazz), clazz
)
}
}
}
override fun fromJson(reader: JsonReader) = when (reader.peek()) {
@Suppress("UNCHECKED_CAST")
override fun fromJson(reader: JsonReader): T? = when (reader.peek()) {
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null // Response was false
else ->
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
else -> {
if (this.clazz == FaultReport::class.java) {
FaultReport(null, null) as T
} else {
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
}
}
}
JsonReader.Token.BEGIN_OBJECT -> objectDelegate?.fromJson(reader)
JsonReader.Token.STRING -> objectDelegate?.fromJson(reader)
JsonReader.Token.NUMBER -> objectDelegate?.fromJson(reader)
JsonReader.Token.BEGIN_OBJECT -> objectDelegate.fromJson(reader)
JsonReader.Token.STRING -> objectDelegate.fromJson(reader)
JsonReader.Token.NUMBER -> objectDelegate.fromJson(reader)
else ->
throw IllegalStateException("Non-object-non-boolean value for @JsonObjectOrFalse field")
}
override fun toJson(writer: JsonWriter, value: T?) =
objectDelegate?.toJson(writer, value) ?: Unit
override fun toJson(writer: JsonWriter, value: T?) = objectDelegate.toJson(writer, value)
}
private fun hasJsonObjectOrFalseAnnotation(annotations: Set<Annotation>?) =
@@ -139,4 +146,14 @@ internal class HoursAdapter {
}
}
}
internal class InstantAdapter {
@FromJson
fun fromJson(value: Long?): Instant? = value?.let {
Instant.ofEpochSecond(it)
}
@ToJson
fun toJson(value: Instant?): Long? = value?.epochSecond
}

View File

@@ -1,9 +1,12 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
@@ -11,40 +14,57 @@ import retrofit2.http.Query
interface GoingElectricApi {
@GET("chargepoints/")
fun getChargepoints(
suspend fun getChargepoints(
@Query("sw_lat") swlat: Double, @Query("sw_lng") sw_lng: Double,
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
@Query("clustering") clustering: Boolean,
@Query("zoom") zoom: Float,
@Query("cluster_distance") clusterDistance: Int,
@Query("cluster_distance") clusterDistance: Int?,
@Query("freecharging") freecharging: Boolean,
@Query("freeparking") freeparking: Boolean,
@Query("min_power") minPower: Int
): Call<ChargepointList>
@Query("min_power") minPower: Int,
@Query("plugs") plugs: String?
): Response<ChargepointList>
@GET("chargepoints/")
fun getChargepointDetail(@Query("ge_id") id: Long): Call<ChargepointList>
@GET("chargepoints/pluglist/")
suspend fun getPlugs(): Response<StringList>
@GET("chargepoints/networklist/")
suspend fun getNetworks(): Response<StringList>
@GET("chargepoints/chargecardlist/")
suspend fun getChargeCards(): Response<StringList>
companion object {
private val cacheSize = 10L * 1024 * 1024; // 10MB
fun create(
apikey: String,
baseurl: String = "https://api.goingelectric.de"
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
): GoingElectricApi {
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
var original = chain.request()
val url = original.url().newBuilder().addQueryParameter("key", apikey).build()
original = original.newBuilder().url(url).build()
chain.proceed(original)
}
.addNetworkInterceptor(StethoInterceptor())
.build()
addNetworkInterceptor(StethoInterceptor())
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
}
}.build()
val moshi = Moshi.Builder()
.add(ChargepointListItemJsonAdapterFactory())
.add(JsonObjectOrFalseAdapter.Factory())
.add(HoursAdapter())
.add(InstantAdapter())
.build()
val retrofit = Retrofit.Builder()

View File

@@ -12,6 +12,7 @@ import kotlinx.android.parcel.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.util.*
@@ -24,6 +25,12 @@ data class ChargepointList(
val chargelocations: List<ChargepointListItem>
)
@JsonClass(generateAdapter = true)
data class StringList(
val status: String,
val result: List<String>
)
sealed class ChargepointListItem
@JsonClass(generateAdapter = true)
@@ -36,7 +43,7 @@ data class ChargeLocation(
val chargepoints: List<Chargepoint>,
@JsonObjectOrFalse val network: String?,
val url: String,
// @Json(name = "fault_report") val faultReport: Boolean, <- Object or false in detail, true or false in overview
@Embedded(prefix="fault_report_") @JsonObjectOrFalse @Json(name = "fault_report") val faultReport: FaultReport?,
val verified: Boolean,
// only shown in details:
@JsonObjectOrFalse val operator: String?,
@@ -101,6 +108,10 @@ data class OpeningHours(
@JsonObjectOrFalse val description: String?,
@Embedded val days: OpeningHoursDays?
) {
val isEmpty: Boolean
get() = description == "Leider noch keine Informationen zu Öffnungszeiten vorhanden."
&& days == null && !twentyfourSeven
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
@@ -128,8 +139,6 @@ data class OpeningHours(
), 0
)
}
} else if (description != null) {
return description
} else {
return ""
}
@@ -204,13 +213,13 @@ data class Coordinate(val lat: Double, val lng: Double) {
@JsonClass(generateAdapter = true)
data class Address(
val city: String,
val country: String,
val postcode: String,
val street: String
@JsonObjectOrFalse val city: String?,
@JsonObjectOrFalse val country: String?,
@JsonObjectOrFalse val postcode: String?,
@JsonObjectOrFalse val street: String?
) {
override fun toString(): String {
return "$street, $postcode $city"
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
}
@@ -236,4 +245,7 @@ data class Chargepoint(val type: String, val power: Double, val count: Int) : Eq
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
}
}
}
@JsonClass(generateAdapter = true)
data class FaultReport(val created: Instant?, val description: String?)

View File

@@ -41,6 +41,10 @@ class AboutFragment : PreferenceFragmentCompat() {
(activity as? MapsActivity)?.openUrl(getString(R.string.privacy_link))
true
}
"faq" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.faq_link))
true
}
"oss_licenses" -> {
LibsBuilder()
.withLicenseShown(true)
@@ -51,6 +55,10 @@ class AboutFragment : PreferenceFragmentCompat() {
.start(requireActivity())
true
}
"donate" -> {
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
else -> super.onPreferenceTreeClick(preference)
}
}

View File

@@ -0,0 +1,64 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DonationAdapter
import net.vonforst.evmap.databinding.FragmentDonateBinding
import net.vonforst.evmap.viewmodel.DonateViewModel
class DonateFragment : Fragment() {
private lateinit var binding: FragmentDonateBinding
private val vm: DonateViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.productsList.apply {
adapter = DonationAdapter().apply {
onClickListener = {
vm.startPurchase(it, requireActivity())
}
}
layoutManager = LinearLayoutManager(context)
}
vm.purchaseSuccessful.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_successful, Snackbar.LENGTH_LONG).show()
})
vm.purchaseFailed.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
}
}

View File

@@ -66,8 +66,13 @@ class FavoritesFragment : Fragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
val favAdapter = FavoritesAdapter(vm).apply {
onClickListener = {
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
}
}
binding.favsList.apply {
adapter = FavoritesAdapter(vm)
adapter = favAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(

View File

@@ -2,7 +2,6 @@ package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
@@ -18,8 +17,6 @@ import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.FiltersAdapter
import net.vonforst.evmap.databinding.FragmentFilterBinding
import net.vonforst.evmap.ui.exitCircularReveal
import net.vonforst.evmap.ui.startCircularReveal
import net.vonforst.evmap.viewmodel.FilterViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -44,12 +41,6 @@ class FilterFragment : Fragment() {
binding.vm = vm
setHasOptionsMenu(true)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
exitAfterTransition()
}
})
return binding.root
}
@@ -75,10 +66,8 @@ class FilterFragment : Fragment() {
)
}
view.startCircularReveal()
toolbar.setNavigationOnClickListener {
exitAfterTransition()
findNavController().popBackStack()
}
}
@@ -92,17 +81,11 @@ class FilterFragment : Fragment() {
R.id.menu_apply -> {
lifecycleScope.launch {
vm.saveFilterValues()
findNavController().popBackStack()
}
exitAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun exitAfterTransition() {
view?.exitCircularReveal {
findNavController().popBackStack()
}
}
}

View File

@@ -7,10 +7,15 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.transition.TransitionManager
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.SharedElementCallback
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
@@ -37,14 +42,16 @@ import com.google.android.gms.maps.model.*
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
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 io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.android.synthetic.main.fragment_map.*
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.REQUEST_LOCATION_PERMISSION
import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
@@ -56,12 +63,12 @@ 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.viewmodel.GalleryViewModel
import net.vonforst.evmap.viewmodel.MapPosition
import net.vonforst.evmap.viewmodel.MapViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
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 {
private lateinit var binding: FragmentMapBinding
@@ -78,8 +85,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private var markers: Map<Marker, ChargeLocation> = emptyMap()
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -87,11 +95,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private lateinit var favToggle: MenuItem
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val value = vm.layersMenuOpen.value
if (value != null && value) {
closeLayersMenu()
return
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
} else if (state == STATE_HIDDEN) {
vm.searchResult.value = null
}
}
}
@@ -178,17 +194,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
binding.fabLayers.setOnClickListener {
openLayersMenu()
}
binding.detailView.goingelectricButton.setOnClickListener {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
}
}
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")
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
val fields = listOf(Place.Field.LAT_LNG)
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
val intent: Intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, fields
)
@@ -221,6 +245,32 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
private fun openLayersMenu() {
binding.fabLayers.tag = false
val materialTransform = MaterialContainerTransform().apply {
startView = binding.fabLayers
endView = binding.layersSheet
pathMotion = MaterialArcMotion()
duration = 250
scrimColor = Color.TRANSPARENT
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
vm.layersMenuOpen.value = true
}
private fun closeLayersMenu() {
binding.fabLayers.tag = true
val materialTransform = MaterialContainerTransform().apply {
startView = binding.layersSheet
endView = binding.fabLayers
pathMotion = MaterialArcMotion()
duration = 200
scrimColor = Color.TRANSPARENT
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
vm.layersMenuOpen.value = false
}
private fun toggleFavorite() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
@@ -240,7 +290,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
backPressedCallback.isEnabled = newState != STATE_HIDDEN
updateBackPressedCallback()
if (vm.layersMenuOpen.value!! && newState !in listOf(BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING, BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN, BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED)) {
closeLayersMenu()
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
@@ -251,8 +305,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
highlightMarker(it)
} else {
bottomSheetBehavior.state = STATE_HIDDEN
unhighlightAllMarkers()
}
})
vm.chargepoints.observe(viewLifecycleOwner, Observer {
@@ -262,6 +318,72 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
val map = this.map ?: return@Observer
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
if (place.viewport != null) {
map.animateCamera(CameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(CameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
searchResultMarker = map.addMarker(MarkerOptions().position(place.latLng!!))
}
updateBackPressedCallback()
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
binding.fabLayers.visibility = if (open) View.GONE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.GONE
updateBackPressedCallback()
})
vm.mapType.observe(viewLifecycleOwner, Observer {
map?.mapType = it
})
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
map?.isTrafficEnabled = it
})
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != STATE_HIDDEN || vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
}
private fun unhighlightAllMarkers() {
markers.forEach { (m, c) ->
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c)
)
)
}
}
private fun highlightMarker(charger: ChargeLocation) {
val marker = markers.inverse[charger] ?: return
// highlight this marker
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger), highlight = true
)
)
animator.animateMarkerBounce(marker)
// un-highlight all other markers
markers.forEach { (m, c) ->
if (m != marker) {
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c)
)
)
}
}
}
private fun updateFavoriteToggle() {
@@ -307,6 +429,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
startPostponedEnterTransition()
} else {
binding.gallery.scrollToPosition(galleryPosition)
// make sure that the app does not freeze waiting for a picture to load
Handler().postDelayed({
startPostponedEnterTransition()
}, 500)
}
binding.detailView.connectors.apply {
@@ -317,7 +443,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.details.apply {
adapter = DetailAdapter()
adapter = DetailAdapter().apply {
onClickListener = {
val charger = vm.chargerSparse.value
if (charger != null) {
when (it.icon) {
R.drawable.ic_location -> {
(activity as? MapsActivity)?.showLocation(charger)
}
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
}
}
}
}
}
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
@@ -332,7 +472,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: GoogleMap) {
this.map = map
map.uiSettings.isTiltGesturesEnabled = false;
map.uiSettings.isTiltGesturesEnabled = false
map.isIndoorEnabled = false
map.uiSettings.isIndoorLevelPickerEnabled = false
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
@@ -354,9 +496,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
map.setOnMapClickListener {
vm.chargerSparse.value = null
if (backPressedCallback.isEnabled) {
backPressedCallback.handleOnBackPressed()
}
}
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
if (mode == Configuration.UI_MODE_NIGHT_YES) {
@@ -366,23 +513,51 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val position = vm.mapPosition.value
if (hasLocationPermission()) {
enableLocation(position == null, false)
} else if (position == null) {
// center the camera on Europe
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.moveCamera(cameraUpdate)
}
val lat = arguments?.optDouble(ARG_LAT)
val lon = arguments?.optDouble(ARG_LON)
var positionSet = false
if (position != null) {
val cameraUpdate =
CameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
} else {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = 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)
}
}
}
})
positionSet = true
}
if (hasLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
if (!positionSet) {
// center the camera on Europe
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.moveCamera(cameraUpdate)
}
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
}
@SuppressLint("MissingPermission")
@@ -422,34 +597,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
val chargepointIds = chargers.map { it.id }.toSet()
markers = markers.filter {
// remove markers that disappeared
markers.entries.toList().forEach {
if (!chargepointIds.contains(it.value.id)) {
val tint = getMarkerTint(it.value)
if (it.key.isVisible) {
animator.animateMarkerDisappear(it.key, tint)
val tint = getMarkerTint(it.value)
val highlight = it.value == vm.chargerSparse.value
animator.animateMarkerDisappear(it.key, tint, highlight)
} else {
it.key.remove()
}
false
} else {
true
markers.remove(it.key)
}
}
markers = markers + chargers.filter {
// add new markers
chargers.filter {
!markers.containsValue(it)
}.map { charger ->
}.forEach { charger ->
val tint = getMarkerTint(charger)
val highlight = charger == vm.chargerSparse.value
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.icon(
chargerIconGenerator.getBitmapDescriptor(tint)
chargerIconGenerator.getBitmapDescriptor(tint, highlight = highlight)
)
)
animator.animateMarkerAppear(marker, tint)
marker to charger
}.toMap()
animator.animateMarkerAppear(marker, tint, highlight)
markers[marker] = charger
}
clusterMarkers = clusters.map { cluster ->
map.addMarker(
MarkerOptions()
@@ -476,18 +652,49 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.map, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_filter -> {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
val filterItem = menu.findItem(R.id.menu_filter)
val filterView = filterItem.actionView
val filterBadge = filterView?.findViewById<TextView>(R.id.filter_badge)
if (filterBadge != null) {
// set up badge showing number of active filters
vm.filtersCount.observe(viewLifecycleOwner, Observer {
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
filterBadge.text = it.toString()
})
}
filterView?.setOnClickListener {
val popup = PopupMenu(requireContext(), it, Gravity.END)
popup.menuInflater.inflate(R.menu.popup_filter, popup.menu)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_edit_filters -> {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
}
R.id.menu_filters_active -> {
vm.filtersActive.value = !vm.filtersActive.value!!
true
}
else -> false
}
}
else -> super.onOptionsItemSelected(item)
val checkItem = popup.menu.findItem(R.id.menu_filters_active)
vm.filtersActive.observe(viewLifecycleOwner, Observer {
checkItem.isChecked = it
})
popup.show()
}
filterView?.setOnLongClickListener {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
}
}
@@ -495,9 +702,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK) {
val place = Autocomplete.getPlaceFromIntent(data!!)
val zoom = 12f
map?.animateCamera(CameraUpdateFactory.newLatLngZoom(place.latLng, zoom))
vm.searchResult.value = Autocomplete.getPlaceFromIntent(data!!)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
@@ -523,4 +728,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
sharedElements[names[0]] = vh.itemView
}
}
companion object {
fun showCharger(charger: ChargeLocation): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, charger.id)
putDouble(ARG_LAT, charger.coordinates.lat)
putDouble(ARG_LON, charger.coordinates.lng)
}
}
}
}

View File

@@ -17,19 +17,21 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
ChargeLocation::class,
BooleanFilterValue::class,
MultipleChoiceFilterValue::class,
SliderFilterValue::class
], version = 2
SliderFilterValue::class,
Plug::class
], version = 6
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun plugDao(): PlugDao
companion object {
private lateinit var context: Context
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
.addMigrations(MIGRATION_2)
.addMigrations(MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6)
.build()
}
@@ -46,5 +48,56 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))");
}
}
private val MIGRATION_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// recreate ChargeLocation table to make postcode nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT NOT NULL, `country` TEXT NOT NULL, `postcode` TEXT, `street` TEXT NOT NULL, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
private val MIGRATION_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `Plug` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))")
}
}
private val MIGRATION_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// recreate ChargeLocation table to make other address fields nullable
db.beginTransaction()
try {
db.execSQL("CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `verified` INTEGER NOT NULL, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, PRIMARY KEY(`id`))");
db.execSQL("INSERT INTO `ChargeLocationNew` SELECT * FROM `ChargeLocation`");
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
private val MIGRATION_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
db.execSQL("ALTER TABLE `ChargeLocation` ADD `fault_report_created` INTEGER")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `fault_report_description` TEXT")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.time.Duration
import java.time.Instant
@Entity
data class Plug(@PrimaryKey val name: String)
@Dao
interface PlugDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg plugs: Plug)
@Delete
suspend fun delete(vararg plugs: Plug)
@Query("SELECT * FROM plug")
fun getAllPlugs(): LiveData<List<Plug>>
}
class PlugRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: PlugDao, private val prefs: PreferenceDataSource
) {
fun getPlugs(): LiveData<List<Plug>> {
scope.launch {
updatePlugs()
}
return dao.getAllPlugs()
}
private suspend fun updatePlugs() {
if (Duration.between(prefs.lastPlugUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getPlugs()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Plug(name))
}
prefs.lastPlugUpdate = Instant.now()
}
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import java.time.Instant
class PreferenceDataSource(context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
@@ -11,4 +12,10 @@ class PreferenceDataSource(context: Context) {
set(value) {
sp.edit().putBoolean("navigate_use_maps", value).apply()
}
var lastPlugUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_plug_update", 0L))
set(value) {
sp.edit().putLong("last_plug_update", value.toEpochMilli()).apply()
}
}

View File

@@ -5,6 +5,7 @@ import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import java.time.Instant
import java.time.LocalTime
class Converters {
@@ -49,11 +50,23 @@ class Converters {
@TypeConverter
fun toLocalTime(value: String?): LocalTime? {
return value.let {
return value?.let {
LocalTime.parse(it)
}
}
@TypeConverter
fun fromInstant(value: Instant?): Long? {
return value?.toEpochMilli()
}
@TypeConverter
fun toInstant(value: Long?): Instant? {
return value?.let {
Instant.ofEpochMilli(it)
}
}
@TypeConverter
fun fromStringSet(value: Set<String>?): String {
return stringSetAdapter.toJson(value)

View File

@@ -2,16 +2,22 @@ package net.vonforst.evmap.ui
import android.content.Context
import android.content.res.ColorStateList
import android.os.Build
import android.text.Html
import android.text.Spanned
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.text.HtmlCompat
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.Chargepoint
@@ -82,25 +88,50 @@ fun setContentDescriptionResource(imageView: ImageView, resource: Int) {
}
@BindingAdapter("tintAvailability")
fun setImageTintAvailability(view: ImageView, available: Int?) {
fun setImageTintAvailability(view: ImageView, available: List<ChargepointStatus>?) {
view.imageTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("textColorAvailability")
fun setTextColorAvailability(view: TextView, available: Int?) {
fun setTextColorAvailability(view: TextView, available: List<ChargepointStatus>?) {
view.setTextColor(availabilityColor(available, view.context))
}
@BindingAdapter("backgroundTintAvailability")
fun setBackgroundTintAvailability(view: View, available: Int?) {
fun setBackgroundTintAvailability(view: View, available: List<ChargepointStatus>?) {
view.backgroundTintList = ColorStateList.valueOf(availabilityColor(available, view.context))
}
@BindingAdapter("selectableItemBackground")
fun applySelectableItemBackground(view: View, apply: Boolean) {
if (apply) {
view.context.obtainStyledAttributes(intArrayOf(R.attr.selectableItemBackground)).use {
view.background = it.getDrawable(0)
}
} else {
view.background = null
}
}
@BindingAdapter("htmlText")
fun setHtmlTextValue(textView: TextView, htmlText: String?) {
if (htmlText == null) {
textView.text = null
} else {
textView.text = HtmlCompat.fromHtml(htmlText, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
private fun availabilityColor(
available: Int?,
status: List<ChargepointStatus>?,
context: Context
): Int = if (available != null) {
if (available > 0) {
): Int = if (status != null) {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
if (unknown) {
ContextCompat.getColor(context, R.color.unknown)
} else if (available > 0) {
ContextCompat.getColor(context, R.color.available)
} else {
ContextCompat.getColor(context, R.color.unavailable)
@@ -108,4 +139,16 @@ private fun availabilityColor(
} else {
val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal))
ta.getColor(0, 0)
}
fun availabilityText(status: List<ChargepointStatus>?): String? {
if (status == null) return null
val total = status.size
val unknown = status.count { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
return if (unknown > 0) {
if (unknown == total) "?" else "$available?"
} else available.toString()
}

View File

@@ -0,0 +1,112 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
class HideOnExpandFabBehavior(context: Context, attrs: AttributeSet) :
FloatingActionButton.Behavior(context, attrs) {
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL || super.onStartNestedScroll(
coordinatorLayout,
child,
directTargetChild,
target,
axes,
type
)
}
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
if (dependency is NestedScrollView) {
try {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
behavior.addBottomSheetCallback(object :
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
onDependentViewChanged(parent, child, dependency)
}
})
return true
} catch (e: IllegalArgumentException) {
}
}
return false
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: FloatingActionButton,
dependency: View
): Boolean {
val behavior = BottomSheetBehaviorGoogleMapsLike.from<View>(dependency)
when (behavior.state) {
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING -> {
}
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
if (child.tag as? Boolean != false) child.show()
}
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED -> {
if (child.tag as? Boolean != false) child.show()
}
else -> {
child.hide()
}
}
return false
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: FloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
super.onNestedScroll(
coordinatorLayout,
child,
target,
dxConsumed,
dyConsumed,
dxUnconsumed,
dyUnconsumed,
type,
consumed
)
if (dyConsumed > 0 && child.getVisibility() == View.VISIBLE) {
// User scrolled down and the FAB is currently visible -> hide the FAB
child.hide();
} else if (dyConsumed < 0 && child.getVisibility() != View.VISIBLE) {
// User scrolled up and the FAB is currently not visible -> show the FAB
child.show();
}
}
}

View File

@@ -10,6 +10,7 @@ import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.widget.TextViewCompat
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.maps.android.ui.IconGenerator
@@ -20,7 +21,6 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
init {
setBackground(context.getDrawable(R.drawable.marker_cluster_bg))
setContentView(makeSquareTextView(context))
setTextAppearance(R.style.TextAppearance_AppCompat_Inverse)
}
private fun makeSquareTextView(context: Context): SquareTextView? {
@@ -34,13 +34,15 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
)
id = com.google.maps.android.R.id.amu_text
setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
}
}
class ChargerIconGenerator(val context: Context) {
data class BitmapData(val tint: Int, val scale: Int, val alpha: Int)
data class BitmapData(val tint: Int, val scale: Int, val alpha: Int, val highlight: Boolean)
val cacheSize = 4 * 1024 * 1024; // 4MiB
val cache = object : LruCache<BitmapData, Bitmap>(cacheSize) {
@@ -50,6 +52,7 @@ class ChargerIconGenerator(val context: Context) {
}
val oversize = 1f // increase to add padding for overshoot scale animation
val icon = R.drawable.ic_map_marker_charging
val highlightIcon = R.drawable.ic_map_marker_highlight
init {
preloadCache()
@@ -64,10 +67,12 @@ class ChargerIconGenerator(val context: Context) {
R.color.charger_11kw,
R.color.charger_low
)
for (tint in tints) {
for (scale in 0..20) {
val data = BitmapData(tint, scale, 255)
cache.put(data, generateBitmap(data))
for (highlight in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..20) {
val data = BitmapData(tint, scale, 255, highlight)
cache.put(data, generateBitmap(data))
}
}
}
}
@@ -75,9 +80,10 @@ class ChargerIconGenerator(val context: Context) {
fun getBitmapDescriptor(
@ColorRes tint: Int,
scale: Int = 20,
alpha: Int = 255
alpha: Int = 255,
highlight: Boolean = false
): BitmapDescriptor? {
val data = BitmapData(tint, scale, alpha)
val data = BitmapData(tint, scale, alpha, highlight)
val cachedImg = cache[data]
return if (cachedImg != null) {
BitmapDescriptorFactory.fromBitmap(cachedImg)
@@ -118,6 +124,18 @@ class ChargerIconGenerator(val context: Context) {
)
vd.draw(canvas)
if (data.highlight) {
val highlightDrawable = context.getDrawable(highlightIcon)!!
highlightDrawable.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
topPadding.toInt() + vd.intrinsicHeight
)
highlightDrawable.alpha = data.alpha
highlightDrawable.draw(canvas)
}
return bm
}
}

View File

@@ -1,12 +1,14 @@
package net.vonforst.evmap.ui
import android.animation.ValueAnimator
import android.view.animation.BounceInterpolator
import androidx.core.animation.addListener
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.gms.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import kotlin.math.max
fun getMarkerTint(charger: ChargeLocation): Int = when {
charger.maxPower >= 100 -> R.color.charger_100kw
@@ -21,7 +23,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
fun animateMarkerAppear(
marker: Marker,
tint: Int
tint: Int,
highlight: Boolean
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
@@ -37,7 +40,7 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
gen.getBitmapDescriptor(tint, scale = scale, highlight = highlight)
)
}
addListener(onEnd = {
@@ -50,7 +53,8 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
fun animateMarkerDisappear(
marker: Marker,
tint: Int
tint: Int,
highlight: Boolean
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
@@ -66,7 +70,7 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
gen.getBitmapDescriptor(tint, scale = scale, highlight = highlight)
)
}
addListener(onEnd = {
@@ -77,4 +81,25 @@ class MarkerAnimator(val gen: ChargerIconGenerator) {
animatingMarkers[marker] = anim
anim.start()
}
fun animateMarkerBounce(marker: Marker) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 700
interpolator = BounceInterpolator()
addUpdateListener { state ->
if (!marker.isVisible) {
cancel()
animatingMarkers.remove(marker)
return@addUpdateListener
}
val t = max(1f - state.animatedValue as Float, 0f) / 2
marker.setAnchor(0.5f, 1.0f + t)
}
}
animatingMarkers[marker] = anim
anim.start()
}
}

View File

@@ -0,0 +1,113 @@
package net.vonforst.evmap.viewmodel
import android.app.Activity
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.adapter.Equatable
class DonateViewModel(application: Application) : AndroidViewModel(application),
PurchasesUpdatedListener {
private var billingClient = BillingClient.newBuilder(application)
.setListener(this)
.enablePendingPurchases()
.build()
init {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingServiceDisconnected() {
}
override fun onBillingSetupFinished(p0: BillingResult?) {
loadProducts()
// consume pending purchases
val purchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP)
purchases.purchasesList.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
}
}
}
})
}
private fun loadProducts() {
val params = SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.INAPP)
.setSkusList(
listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) +
if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
)
.build()
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
products.value = Resource.success(details
.sortedBy { it.priceAmountMicros }
.map { DonationItem(it) }
)
} else {
products.value = Resource.error(result.debugMessage, null)
}
}
}
val products: MutableLiveData<Resource<List<DonationItem>>> by lazy {
MutableLiveData<Resource<List<DonationItem>>>().apply {
value = Resource.loading(null)
}
}
val purchaseSuccessful = SingleLiveEvent<Nothing>()
val purchaseFailed = SingleLiveEvent<Nothing>()
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
for (purchase in purchases) {
val purchaseToken = purchase.purchaseToken
consumePurchase(purchaseToken)
}
} else if (result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
// Handle an error caused by a user cancelling the purchase flow.
} else {
purchaseFailed.call()
}
}
private fun consumePurchase(purchaseToken: String, showSuccess: Boolean = true) {
val params = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
billingClient.consumeAsync(params) { _, _ ->
if (showSuccess) purchaseSuccessful.call()
}
}
fun startPurchase(it: DonationItem, activity: Activity) {
val flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(it.sku)
.build()
val response = billingClient.launchBillingFlow(activity, flowParams)
if (response.responseCode != BillingClient.BillingResponseCode.OK) {
purchaseFailed.call()
}
}
override fun onCleared() {
billingClient.endConnection()
}
}
data class DonationItem(val sku: SkuDetails) : Equatable

View File

@@ -17,7 +17,7 @@ import net.vonforst.evmap.storage.AppDatabase
class FavoritesViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
val favorites: LiveData<List<ChargeLocation>> by lazy {
@@ -81,18 +81,18 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
data class FavoritesListItem(
val charger: ChargeLocation,
val available: Resource<Int>,
val available: Resource<List<ChargepointStatus>>,
val total: Int,
val distance: Double?
) : Equatable
private fun totalAvailable(id: Long): Resource<Int> {
private fun totalAvailable(id: Long): Resource<List<ChargepointStatus>> {
val availability = availability.value?.get(id) ?: return Resource.error(null, null)
if (availability.status != Status.SUCCESS) {
return Resource(availability.status, null, availability.message)
} else {
val values = availability.data?.status?.values ?: return Resource.error(null, null)
return Resource.success(values.sumBy { it.filter { it == ChargepointStatus.AVAILABLE }.size })
return Resource.success(values.flatten())
}
}

View File

@@ -5,48 +5,119 @@ import androidx.databinding.BaseObservable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.viewModelScope
import androidx.room.Entity
import androidx.room.PrimaryKey
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.Plug
import net.vonforst.evmap.storage.PlugRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.abs
import kotlin.reflect.KClass
import kotlin.reflect.full.cast
fun getFilters(application: Application): List<Filter<FilterValue>> {
return listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
SliderFilter(application.getString(R.string.filter_min_power), "min_power", 350)
)
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 100, 150, 200, 250, 300, 350)
internal fun mapPower(i: Int) = powerSteps[i]
internal fun mapPowerInverse(power: Int) = powerSteps
.mapIndexed { index, v -> abs(v - power) to index }
.minBy { it.first }?.second ?: 0
internal fun getFilters(
application: Application,
plugs: LiveData<List<Plug>>
): LiveData<List<Filter<FilterValue>>> {
return MediatorLiveData<List<Filter<FilterValue>>>().apply {
val plugNames = mapOf(
Chargepoint.TYPE_1 to application.getString(R.string.plug_type_1),
Chargepoint.TYPE_2 to application.getString(R.string.plug_type_2),
Chargepoint.TYPE_3 to application.getString(R.string.plug_type_3),
Chargepoint.CCS to application.getString(R.string.plug_ccs),
Chargepoint.SCHUKO to application.getString(R.string.plug_schuko),
Chargepoint.CHADEMO to application.getString(R.string.plug_chademo),
Chargepoint.SUPERCHARGER to application.getString(R.string.plug_supercharger),
Chargepoint.CEE_BLAU to application.getString(R.string.plug_cee_blau),
Chargepoint.CEE_ROT to application.getString(R.string.plug_cee_rot)
)
addSource(plugs) { plugs ->
val plugMap = plugs.map { plug ->
plug.name to (plugNames[plug.name] ?: plug.name)
}.toMap()
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
SliderFilter(
application.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
application.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO)
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
"min_connectors",
10
)
)
}
}
}
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>,
active: LiveData<Boolean>? = null
): MediatorLiveData<List<FilterWithValue<out FilterValue>>> =
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
listOf(filters, filterValues, active).forEach {
if (it == null) return@forEach
addSource(it) {
val filters = filters.value ?: return@addSource
value = if (active != null && !active.value!!) {
filters.map { filter ->
FilterWithValue(filter, filter.defaultValue())
}
} else {
val values = filterValues.value ?: return@addSource
filters.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
}
}
}
}
class FilterViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private val filters = getFilters(application)
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val filters: LiveData<List<Filter<FilterValue>>> by lazy {
getFilters(application, plugs)
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues()
}
val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
addSource(filterValues) { values ->
value = if (values != null) {
filters.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
} else {
null
}
}
}
filtersWithValue(filters, filterValues)
}
suspend fun saveFilterValues() {
@@ -72,16 +143,20 @@ data class BooleanFilter(override val name: String, override val key: String) :
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>
val choices: Map<String, String>,
val commonChoices: Set<String>? = null
) : Filter<MultipleChoiceFilterValue>() {
override val valueClass: KClass<MultipleChoiceFilterValue> = MultipleChoiceFilterValue::class
override fun defaultValue() = MultipleChoiceFilterValue(key, emptySet(), true)
override fun defaultValue() = MultipleChoiceFilterValue(key, mutableSetOf(), true)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int
val max: Int,
val mapping: ((Int) -> Int) = { it },
val inverseMapping: ((Int) -> Int) = { it },
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, 0)
@@ -100,9 +175,20 @@ data class BooleanFilterValue(
@Entity
data class MultipleChoiceFilterValue(
@PrimaryKey override val key: String,
var values: Set<String>,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue()
) : FilterValue() {
override fun equals(other: Any?): Boolean {
if (other == null || other !is MultipleChoiceFilterValue) return false
if (key != other.key) return false
return if (all) {
other.all
} else {
!other.all && values == other.values
}
}
}
@Entity
data class SliderFilterValue(

View File

@@ -2,7 +2,10 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.libraries.places.api.model.Place
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
@@ -11,16 +14,30 @@ import net.vonforst.evmap.api.goingelectric.ChargepointList
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.Plug
import net.vonforst.evmap.storage.PlugRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlin.reflect.full.cast
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
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
else -> null
}
}
class MapViewModel(application: Application, geApiKey: String) : AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
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>()
@@ -32,20 +49,21 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues()
}
private val filters = getFilters(application)
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val filters = getFilters(application, plugs)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
addSource(filterValues) {
val values = filterValues.value
if (values != null) {
value = filters.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
} else {
value = null
filtersWithValue(filters, filterValues, filtersActive)
}
val filtersCount: LiveData<Int> by lazy {
MediatorLiveData<Int>().apply {
value = 0
addSource(filtersWithValue) { filtersWithValue ->
value = filtersWithValue.count {
it.filter.defaultValue() != it.value
}
}
}
@@ -106,11 +124,42 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
val layersMenuOpen: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val favorites: LiveData<List<ChargeLocation>> by lazy {
db.chargeLocationsDao().getAllChargeLocations()
}
val searchResult: MutableLiveData<Place> by lazy {
MutableLiveData<Place>()
}
val mapType: MutableLiveData<Int> by lazy {
MutableLiveData<Int>().apply {
value = GoogleMap.MAP_TYPE_NORMAL
}
}
val mapTrafficEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val filtersActive: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = true
}
}
fun setMapType(type: Int) {
mapType.value = type
}
fun insertFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().insert(charger)
@@ -127,49 +176,67 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
mapPosition: MapPosition,
filters: List<FilterWithValue<out FilterValue>>
) {
chargepointLoader?.cancel()
chargepoints.value = Resource.loading(chargepoints.value?.data)
val bounds = mapPosition.bounds
val zoom = mapPosition.zoom
getChargepointsWithFilters(bounds, zoom, filters).enqueue(object :
Callback<ChargepointList> {
override fun onFailure(call: Call<ChargepointList>, t: Throwable) {
chargepoints.value = Resource.error(t.message, chargepoints.value?.data)
t.printStackTrace()
}
override fun onResponse(
call: Call<ChargepointList>,
response: Response<ChargepointList>
) {
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargepoints.value =
Resource.error(response.message(), chargepoints.value?.data)
} else {
chargepoints.value = Resource.success(response.body()!!.chargelocations)
}
}
})
chargepointLoader = viewModelScope.launch {
chargepoints.value = getChargepointsWithFilters(bounds, zoom, filters)
}
}
private fun getChargepointsWithFilters(
private suspend fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: List<FilterWithValue<out FilterValue>>
): Call<ChargepointList> {
): Resource<List<ChargepointListItem>> {
val freecharging =
(filters.find { it.value.key == "freecharging" }!!.value as BooleanFilterValue).value
val freeparking =
(filters.find { it.value.key == "freeparking" }!!.value as BooleanFilterValue).value
val minPower =
(filters.find { it.value.key == "min_power" }!!.value as SliderFilterValue).value
val minConnectors =
(filters.find { it.value.key == "min_connectors" }!!.value as SliderFilterValue).value
return api.getChargepoints(
val connectorsVal =
filters.find { it.value.key == "connectors" }!!.value as MultipleChoiceFilterValue
val connectors = if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
// do not use clustering if filters need to be applied locally.
val useClustering = minConnectors <= 1 && zoom < 13
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
val response = api.getChargepoints(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude,
clustering = zoom < 13, zoom = zoom,
clusterDistance = 70, freecharging = freecharging, minPower = minPower,
freeparking = freeparking
clustering = useClustering, zoom = zoom,
clusterDistance = clusterDistance, freecharging = freecharging, minPower = minPower,
freeparking = freeparking, plugs = connectors
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), chargepoints.value?.data)
} else {
val data = response.body()!!.chargelocations.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is ChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
} else {
true
}
}
return Resource.success(data)
}
}
private suspend fun loadAvailability(charger: ChargeLocation) {

View File

@@ -1,7 +1,10 @@
package net.vonforst.evmap.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.*
import java.util.concurrent.atomic.AtomicBoolean
inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
object : ViewModelProvider.Factory {
@@ -32,4 +35,31 @@ data class Resource<out T>(val status: Status, val data: T?, val message: String
return Resource(Status.LOADING, data, null)
}
}
}
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val mPending: AtomicBoolean = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner, Observer {
if (mPending.compareAndSet(true, false)) {
observer.onChanged(it)
}
})
}
@MainThread
override fun setValue(@Nullable t: T?) {
mPending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
}

View File

@@ -0,0 +1,4 @@
<vector android:height="15.811624dp" android:viewportHeight="131.5"
android:viewportWidth="199.6" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M197.544,65.685l-9.2,-4.8l8,-4.2c2.7,-1.4 2.7,-3.8 0,-5.2l-8.6,-4.5l8.6,-4.5c2.7,-1.4 2.7,-3.8 0,-5.2l-68.9,-36.1c-2.7,-1.4 -7.2,-1.4 -9.9,0l-115.5,59.7c-2.7,1.4 -2.7,3.7 0,5.1l8.8,4.5l-8.8,4.6c-2.7,1.4 -2.7,3.7 0,5.1l9.4,4.8l-8.2,4.3c-2.7,1.4 -2.7,3.7 0,5.1l70.4,36.2c2.7,1.4 7.2,1.4 9.9,0l114,-59.6C200.344,69.385 200.344,67.085 197.544,65.685L197.544,65.685zM123.144,18.785L105.844,38.685c-0.9,1 -0.6,2.3 0.6,2.9l13.7,7.1c1.2,0.6 1.2,1.6 0,2.2l-43.1,22.3c-1.2,0.6 -1.4,0.3 -0.6,-0.7l17.3,-19.9c0.9,-1 0.6,-2.3 -0.6,-2.9l-13.7,-7.1c-1.2,-0.6 -1.2,-1.6 0,-2.2l43.1,-22.3C123.744,17.485 123.944,17.785 123.144,18.785L123.144,18.785z"/>
</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,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</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="M11.99,18.54l-7.37,-5.73L3,14.07l9,7 9,-7 -1.63,-1.27zM12,16l7.36,-5.73L21,9l-9,-7 -9,7 1.63,1.27L12,16zM12,4.53L17.74,9 12,13.47 6.26,9 12,4.53z" />
</vector>

View File

@@ -6,9 +6,7 @@
<path
android:fillColor="#FFFFFF"
android:pathData="M109.8,0h13.6c33.9,1.9 67.1,18.5 87.7,45.8c13.5,17.2 21,38.6 22.7,60.3v8.1c-0.8,42.1 -27.7,76.6 -51,109.4c-26.2,37 -50.4,77.3 -57.1,122.9c-1.8,7.7 0.4,18.5 -8.9,22c-2.2,-1.7 -4.7,-3.1 -6.2,-5.4c-2.7,-25.5 -9.1,-50.7 -20,-73.9c-12.3,-27.1 -29.5,-51.6 -47,-75.6C33,199 23,184.2 14.7,168.3c-13,-23.8 -17.9,-51.9 -12.5,-78.6c4.4,-21.1 15.4,-40.6 30.6,-55.7C53.3,14 81.1,1.8 109.8,0z" />
<!--<path android:fillColor="#802C27" android:pathData="M107.2,74.1c18.9,-4.8 40.4,5.5 47.7,23.7c6.1,14.5 1.9,32.5 -9.9,42.9c-12.6,11.5 -32.4,14 -47.5,6c-13.9,-6.8 -23,-22.6 -21.3,-38.1C77.6,92 91.1,77.7 107.2,74.1z"/>-->
<path
android:fillColor="#808080"
android:pathData="M90.9,57.3v68.2h18.6v55.8l43.4,-74.4h-24.8l24.8,-49.6H90.9z" />
<!--<path android:fillColor="#802C27" android:pathData="M159,85.3L159,85.3l-20.8,-20.9l-5.9,5.9l11.8,11.8c-5.3,2 -9,7.1 -9,13.1c0,7.7 6.3,14 14,14c2,0 3.9,-0.4 5.6,-1.2v40.4c0,3.1 -2.5,5.6 -5.6,5.6s-5.6,-2.5 -5.6,-5.6v-25.2c0,-6.2 -5,-11.2 -11.2,-11.2h-5.6V72.8c0,-6.2 -5,-11.2 -11.2,-11.2H81.8c-6.2,0 -11.2,5 -11.2,11.2v89.7h56.1v-42.1h8.4v28c0,7.7 6.3,14 14,14s14,-6.3 14,-14V95.2C163.1,91.3 161.6,87.8 159,85.3M149.1,100.8c-3.1,0 -5.6,-2.5 -5.6,-5.6c0,-3.1 2.5,-5.6 5.6,-5.6s5.6,2.5 5.6,5.6C154.7,98.3 152.2,100.8 149.1,100.8M93.1,145.6v-25.2H81.8l22.4,-42.1v28h11.2L93.1,145.6z"/>-->
</vector>

View File

@@ -0,0 +1,12 @@
<vector android:height="44.11976dp"
android:viewportHeight="368.4"
android:viewportWidth="233.8"
android:width="28dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<!--<path
android:fillColor="#FFFFFF"
android:pathData="M109.8,0h13.6c33.9,1.9 67.1,18.5 87.7,45.8c13.5,17.2 21,38.6 22.7,60.3v8.1c-0.8,42.1 -27.7,76.6 -51,109.4c-26.2,37 -50.4,77.3 -57.1,122.9c-1.8,7.7 0.4,18.5 -8.9,22c-2.2,-1.7 -4.7,-3.1 -6.2,-5.4c-2.7,-25.5 -9.1,-50.7 -20,-73.9c-12.3,-27.1 -29.5,-51.6 -47,-75.6C33,199 23,184.2 14.7,168.3c-13,-23.8 -17.9,-51.9 -12.5,-78.6c4.4,-21.1 15.4,-40.6 30.6,-55.7C53.3,14 81.1,1.8 109.8,0z" />-->
<path
android:fillColor="#FFFFFF"
android:pathData="M90.9,57.3v68.2h18.6v55.8l43.4,-74.4h-24.8l24.8,-49.6H90.9z" />
</vector>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/actionButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:focusable="true">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:srcCompat="@drawable/ic_filter" />
<TextView
android:id="@+id/filter_badge"
android:layout_width="wrap_content"
android:layout_height="16dp"
android:minWidth="15dp"
android:layout_gravity="right|end|bottom"
android:layout_marginEnd="-5dp"
android:layout_marginBottom="6dp"
android:background="@drawable/rounded_rect_4dp"
android:backgroundTint="?colorPrimary"
android:gravity="center"
android:padding="0dp"
android:textColor="@android:color/white"
android:visibility="gone"
tools:text="0"
android:textSize="10sp" />
</FrameLayout>

View File

@@ -184,7 +184,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView13"
app:layout_constraintTop_toBottomOf="@+id/btnChargeprice"
tools:itemCount="3"
tools:listitem="@layout/item_detail" />
@@ -230,6 +230,18 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/textView" />
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.MaterialComponents.Button.OutlinedButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/go_to_chargeprice"
app:icon="@drawable/ic_chargeprice"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.viewmodel.DonateViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<variable
name="vm"
type="DonateViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<TextView
android:id="@+id/textView20"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/donations_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/products_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:data="@{vm.products.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView20"
tools:listitem="@layout/item_donation" />
<ProgressBar
android:id="@+id/progressBar3"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/products_list"
app:goneUnless="@{vm.products.status == Status.LOADING}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -11,28 +11,73 @@
type="FavoritesViewModel" />
</data>
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize" />
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/favs_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:data="@{vm.listData}" />
</LinearLayout>
android:layout_width="0dp"
android:layout_height="0dp"
app:data="@{vm.listData}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
app:goneUnless="@{vm.listData.size() == 0}"
app:layout_constraintBottom_toTopOf="@+id/textView19"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
app:layout_constraintVertical_chainStyle="packed"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/ev_anim" />
<TextView
android:id="@+id/textView19"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:breakStrategy="balanced"
android:gravity="center_horizontal"
android:text="@string/favorites_empty_state"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorSecondary"
app:goneUnless="@{vm.listData.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/animation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -103,10 +103,10 @@
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_location"
app:backgroundTint="@android:color/white"
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:isFabActive="@{ vm.myLocationEnabled }"
app:layout_behavior=".ui.HideOnScrollFabBehavior" />
app:layout_behavior="net.vonforst.evmap.ui.HideOnScrollFabBehavior" />
<androidx.core.widget.NestedScrollView
android:id="@+id/bottom_sheet"
@@ -120,7 +120,7 @@
app:behavior_peekHeight="@dimen/peek_height"
app:bottomsheetbehavior_defaultState="stateHidden"
app:layout_behavior="@string/BottomSheetBehaviorGoogleMapsLike"
tools:bottomsheetbehavior_defaultState="stateAnchorPoint">
tools:bottomsheetbehavior_defaultState="stateHidden">
<include
android:id="@+id/detail_view"
@@ -148,5 +148,39 @@
android:layout_height="wrap_content"
app:layout_behavior="@string/MergedAppBarLayoutBehavior"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_layers"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:layout_gravity="top|end"
android:layout_marginEnd="12dp"
android:layout_marginTop="96dp"
android:tint="?colorControlNormal"
android:elevation="-1dp"
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:fabSize="mini"
app:srcCompat="@drawable/ic_layers"
app:layout_behavior="net.vonforst.evmap.ui.HideOnExpandFabBehavior"/>
<androidx.cardview.widget.CardView
android:id="@+id/layers_sheet"
android:layout_height="wrap_content"
android:layout_width="200dp"
android:layout_gravity="top|end"
android:layout_marginEnd="8dp"
android:layout_marginTop="96dp"
android:elevation="-1dp"
android:visibility="gone"
tools:visibility="visible">
<include
android:id="@+id/layers"
layout="@layout/map_layers"
app:vm="@{vm}" />
</androidx.cardview.widget.CardView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@@ -6,6 +6,7 @@
<data>
<import type="net.vonforst.evmap.adapter.ConnectorAdapter.ChargepointWithAvailability" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="item"
@@ -29,7 +30,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tintAvailability="@{item.available}"
app:tintAvailability="@{item.status}"
tools:tint="@color/available"
tools:srcCompat="@drawable/ic_connector_typ2" />
@@ -43,7 +44,7 @@
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView"
app:goneUnless="@{item.available == null}"
app:goneUnless="@{item.status == null}"
tools:visibility="gone"
tools:text="×99" />
@@ -53,15 +54,15 @@
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="30dp"
android:text="@{String.format(&quot;%d/%d&quot;, item.available, item.chargepoint.count)}"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.status), item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:textColor="@android:color/white"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView"
app:goneUnless="@{item.available != null}"
app:backgroundTintAvailability="@{item.available}"
app:goneUnless="@{item.status != null}"
app:backgroundTintAvailability="@{item.status}"
tools:backgroundTint="@color/available"
tools:text="80/99" />
@@ -77,7 +78,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView"
app:textColorAvailability="@{item.available}"
app:textColorAvailability="@{item.status}"
tools:textColor="@color/available"
tools:text="350 kW" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -13,7 +13,9 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:clickable="@{item.clickable}"
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
@@ -56,6 +58,7 @@
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="item"
type="net.vonforst.evmap.viewmodel.DonationItem" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:padding="16dp">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.sku.title}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView21"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Spende" />
<TextView
android:id="@+id/textView21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.sku.price}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="1,00 €" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>

View File

@@ -7,6 +7,7 @@
<import type="net.vonforst.evmap.api.UtilsKt" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="item"
@@ -16,7 +17,8 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
android:padding="16dp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/textView15"
@@ -32,10 +34,12 @@
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.address.toString()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView15"
tools:text="Beispielstraße 10, 12345 Berlin" />
@@ -44,10 +48,12 @@
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.formatChargepoints()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
@@ -68,7 +74,7 @@
android:layout_height="wrap_content"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%d/%d&quot;, item.available.data, item.total)}"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<variable
name="item"
type="FilterWithValue&lt;MultipleChoiceFilterValue&gt;" />
<variable
name="showingAll"
type="boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView17"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Connectors" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chip_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:animateLayoutChanges="true"
app:chipSpacingVertical="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnAll">
<com.google.android.material.chip.Chip
android:id="@+id/chipMore"
style="@style/Widget.MaterialComponents.Chip.Action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{showingAll ? @string/show_less : @string/show_more}"
app:chipMinTouchTargetSize="40dp" />
</com.google.android.material.chip.ChipGroup>
<Button
android:id="@+id/btnAll"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all"
app:layout_constraintBaseline_toBaselineOf="@+id/textView17"
app:layout_constraintEnd_toStartOf="@+id/btnNone" />
<Button
android:id="@+id/btnNone"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog.Flush"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/none"
app:layout_constraintBaseline_toBaselineOf="@+id/textView17"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.Chip xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.MaterialComponents.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:chipMinTouchTargetSize="40dp"
tools:text="Typ 2" />

View File

@@ -18,6 +18,10 @@
<variable
name="progress"
type="int" />
<variable
name="mappedValue"
type="int" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
@@ -46,7 +50,7 @@
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:max="@{((SliderFilter) item.filter).max}"
android:progress="@={item.value.value}"
android:progress="@={progress}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView18"
app:layout_constraintStart_toStartOf="parent"
@@ -57,7 +61,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@{progress + &quot; kW&quot;}"
android:text="@{String.valueOf(mappedValue) + ' ' + ((SliderFilter) item.filter).unit}"
app:layout_constraintBottom_toBottomOf="@+id/seekBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/seekBar"

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="net.vonforst.evmap.viewmodel.MapViewModel" />
<import type="com.google.android.gms.maps.GoogleMap" />
<variable
name="vm"
type="MapViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/textView22"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/map_type"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<RadioGroup
android:id="@+id/radioGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView22">
<RadioButton
android:id="@+id/rbStandard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(GoogleMap.MAP_TYPE_NORMAL)}"
android:onClick="@{() -> vm.setMapType(GoogleMap.MAP_TYPE_NORMAL)}"
android:text="@string/map_type_normal" />
<RadioButton
android:id="@+id/rbSatellite"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(GoogleMap.MAP_TYPE_HYBRID)}"
android:onClick="@{() -> vm.setMapType(GoogleMap.MAP_TYPE_HYBRID)}"
android:text="@string/map_type_satellite" />
<RadioButton
android:id="@+id/rbTerrain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@{vm.mapType.equals(GoogleMap.MAP_TYPE_TERRAIN)}"
android:onClick="@{() -> vm.setMapType(GoogleMap.MAP_TYPE_TERRAIN)}"
android:text="@string/map_type_terrain" />
</RadioGroup>
<TextView
android:id="@+id/textView23"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/map_details"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/radioGroup" />
<CheckBox
android:id="@+id/cbTraffic"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/map_traffic"
android:checked="@={vm.mapTrafficEnabled}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView23" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -6,5 +6,6 @@
android:id="@+id/menu_filter"
android:title="@string/menu_filter"
android:icon="@drawable/ic_filter"
app:showAsAction="ifRoom" />
app:showAsAction="ifRoom"
app:actionLayout="@layout/action_filter" />
</menu>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_filters_active"
android:title="@string/menu_filters_active"
android:checkable="true"
android:checked="true" />
<item
android:id="@+id/menu_edit_filters"
android:title="@string/menu_edit_filters" />
</menu>

View File

@@ -29,7 +29,11 @@
android:id="@+id/about"
android:name="net.vonforst.evmap.fragment.AboutFragment"
android:label="@string/about"
tools:layout="@layout/fragment_preference" />
tools:layout="@layout/fragment_preference">
<action
android:id="@+id/action_about_to_donateFragment"
app:destination="@id/donate" />
</fragment>
<fragment
android:id="@+id/settings"
android:name="net.vonforst.evmap.fragment.SettingsFragment"
@@ -44,10 +48,19 @@
android:id="@+id/favs"
android:name="net.vonforst.evmap.fragment.FavoritesFragment"
android:label="@string/menu_favs"
tools:layout="@layout/fragment_favorites" />
tools:layout="@layout/fragment_favorites">
<action
android:id="@+id/action_favs_to_map"
app:destination="@id/map" />
</fragment>
<fragment
android:id="@+id/filter"
android:name="net.vonforst.evmap.fragment.FilterFragment"
android:label="@string/menu_filter"
tools:layout="@layout/fragment_filter" />
<fragment
android:id="@+id/donate"
android:name="net.vonforst.evmap.fragment.DonateFragment"
android:label="@string/donate"
tools:layout="@layout/fragment_donate" />
</navigation>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -47,4 +47,36 @@
<string name="filter_free">Nur kostenlose Ladesäulen</string>
<string name="filter_min_power">Minimale Leistung</string>
<string name="filter_free_parking">Nur Ladesäulen mit kostenlosem Parkplatz</string>
<string name="filter_min_connectors">Mindestzahl Anschlüsse</string>
<string name="filter_connectors">Anschlüsse</string>
<string name="plug_type_1">Typ 1</string>
<string name="plug_type_2">Typ 2</string>
<string name="plug_type_3">Typ 3a</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_supercharger">Tesla Supercharger</string>
<string name="plug_cee_blau">CEE Blau</string>
<string name="plug_cee_rot">CEE Rot</string>
<string name="all">alle</string>
<string name="none">keine</string>
<string name="show_more">mehr…</string>
<string name="show_less">weniger…</string>
<string name="favorites_empty_state">Wenn du Ladestationen als Favorit markierst, tauchen sie hier auf.</string>
<string name="donate">Spenden</string>
<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="donation_successful">Vielen Dank! ❤️</string>
<string name="donation_failed">Etwas ist schiefgelaufen. 😕</string>
<string name="map_type_normal">Standard</string>
<string name="map_type_satellite">Satellit</string>
<string name="map_type_terrain">Gelände</string>
<string name="map_type">Kartentyp</string>
<string name="map_details">Kartendetails</string>
<string name="map_traffic">Verkehr</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filter aktiv</string>
<string name="menu_edit_filters">Filter bearbeiten…</string>
<string name="go_to_chargeprice"><![CDATA[Preisvergleich<br/><small>mit Chargeprice.app</small>]]></string>
<string name="fault_report">Störungsmeldung</string>
<string name="fault_report_date">Störungsmeldung (Letztes Update: %s)</string>
</resources>

View File

@@ -11,5 +11,6 @@
<color name="charger_low">#9e9e9e</color>
<color name="available">#4caf50</color>
<color name="unavailable">#f44336</color>
<color name="unknown">#9e9e9e</color>
<color name="status_bar_scrim">#C3000000</color>
</resources>

View File

@@ -3,4 +3,5 @@
<string name="shared_element_picture">picture</string>
<string name="github_link">https://github.com/johan12345/EVMap</string>
<string name="privacy_link">https://evmap.vonforst.net/privacy.html</string>
<string name="faq_link">https://evmap.vonforst.net/faq.html</string>
</resources>

View File

@@ -46,4 +46,36 @@
<string name="filter_free">Only free chargers</string>
<string name="filter_min_power">Minimum power</string>
<string name="filter_free_parking">Only chargers with free parking</string>
<string name="filter_min_connectors">Minimum number of connectors</string>
<string name="filter_connectors">Connectors</string>
<string name="plug_type_1">Type 1</string>
<string name="plug_type_2">Type 2</string>
<string name="plug_type_3">Type 3a</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_supercharger">Tesla Supercharger</string>
<string name="plug_cee_blau">CEE Blue</string>
<string name="plug_cee_rot">CEE Red</string>
<string name="all">all</string>
<string name="none">none</string>
<string name="show_more">more…</string>
<string name="show_less">less…</string>
<string name="favorites_empty_state">If you add chargers as favorites, they will show up here.</string>
<string name="donate">Donate</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 30% off every donation.</string>
<string name="donation_successful">Thank you! ❤️</string>
<string name="donation_failed">Something went wrong. 😕</string>
<string name="map_type_normal">Default</string>
<string name="map_type_satellite">Satellite</string>
<string name="map_type_terrain">Terrain</string>
<string name="map_type">Map type</string>
<string name="map_details">Map details</string>
<string name="map_traffic">Traffic</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filters active</string>
<string name="menu_edit_filters">Edit filters…</string>
<string name="go_to_chargeprice">Compare prices</string>
<string name="fault_report">Fault report</string>
<string name="fault_report_date">Fault report (last update: %s)</string>
</resources>

View File

@@ -14,8 +14,16 @@
android:title="@string/copyright"
android:summary="@string/copyright_summary" />
<Preference
android:key="faq"
android:title="@string/faq" />
<Preference
android:key="donate"
android:title="@string/donate" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/other">
<Preference
android:key="github_link"

View File

@@ -0,0 +1,26 @@
package net.vonforst.evmap.viewmodel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FilterViewModelTest {
@Test
fun testPowerMapping() {
val sliderValues = powerSteps.indices.toList()
val mappedValues = (sliderValues).map(::mapPower)
assertTrue(mappedValues.distinct() == mappedValues)
assertEquals(350, mappedValues.last())
assertEquals(0, mappedValues.first())
val reverseMappedValues = mappedValues.map(::mapPowerInverse)
assertEquals(sliderValues, reverseMappedValues)
}
@Test
fun testPowerMappingInbetween() {
val sliderValue = 54
assertEquals(50, mapPower(mapPowerInverse(sliderValue)))
}
}

View File

@@ -0,0 +1,24 @@
package net.vonforst.evmap.viewmodel
import org.junit.Test
class MapViewModelTest {
@Test
fun testGetClusterDistance() {
var zoom = 0.0f
var previousDistance: Int? = 999
while (zoom < 20.0f) {
val distance = getClusterDistance(zoom)
if (previousDistance != null) {
if (distance != null) {
assert(distance <= previousDistance)
}
} else {
assert(distance == null)
}
previousDistance = distance
zoom += 0.1f
}
}
}

View File

@@ -8,7 +8,7 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.0-beta03'
classpath 'com.android.tools.build:gradle:4.0.0-rc01'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"