Compare commits

..

23 Commits
0.0.2 ... 0.0.3

Author SHA1 Message Date
Johan von Forstner
cd902f86a4 version 0.0.3 2020-04-23 09:52:57 +02:00
Johan von Forstner
c3b583772b add share button 2020-04-23 09:45:49 +02:00
Johan von Forstner
b19dab7e47 AvailabilityDetector implement special case for load balancing (#3) 2020-04-23 09:32:00 +02:00
Johan von Forstner
4bea049a7b create test for matchChargepoints function 2020-04-23 09:17:54 +02:00
Johan von Forstner
5c4dd958f9 AvailabilityDetector: set maximum distance to 150 meters (fixes #4) 2020-04-23 09:17:36 +02:00
Johan von Forstner
dba9bf6d10 add type 1 SVG (#6) 2020-04-23 08:49:40 +02:00
Johan von Forstner
873a54c3ca add type 1 plug icon (#6) 2020-04-23 08:46:32 +02:00
Johan von Forstner
ed1647bb55 add progress bar to favorites view 2020-04-22 20:44:03 +02:00
Johan von Forstner
cfb6af28c0 show availability in favorites view 2020-04-22 20:33:06 +02:00
Johan von Forstner
6be926c308 add docs and test for distanceBetween function 2020-04-22 19:56:57 +02:00
Johan von Forstner
b1c2844360 NewMotionAvailabilityDetector: add remaining plug types 2020-04-22 19:43:15 +02:00
Johan von Forstner
22ff42f3cf increase corner radius of bottom panel 2020-04-21 21:16:06 +02:00
Johan von Forstner
918a6eee58 change description in settings 2020-04-21 21:11:39 +02:00
Johan von Forstner
2dcf03f831 fix bug where gallery scrolling would get stuck 2020-04-21 20:59:32 +02:00
Johan von Forstner
f2e7cfbb36 update CustomBottomSheetBehavior library 2020-04-21 09:17:35 +02:00
Johan von Forstner
d4d394dbd3 show keyboard when opening search (fixes #5) 2020-04-21 08:51:25 +02:00
Johan von Forstner
1f71b435c4 display coordinates in detail view 2020-04-20 20:44:03 +02:00
Johan von Forstner
ec19a55db8 add setting for how to open Maps app 2020-04-20 20:15:36 +02:00
Johan von Forstner
84bbdaf4ec add favorites view 2020-04-19 22:19:29 +02:00
Johan von Forstner
febc72f190 add toolbar to detail view 2020-04-19 16:24:37 +02:00
Johan von Forstner
20a1dea2cd Build a Room database for favorites 2020-04-19 16:24:37 +02:00
Johan von Forstner
6e93e602b1 fix distanceBetween function 2020-04-17 21:41:18 +02:00
Johan von Forstner
083643fa41 fix occasional crash
IllegalArgumentException: Unmanaged descriptor
2020-04-17 10:16:52 +02:00
37 changed files with 1128 additions and 153 deletions

View File

@@ -0,0 +1,19 @@
<svg id="Ebene_5" data-name="Ebene 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>
.cls-1,.cls-2,.cls-3{fill:none;}.cls-2,.cls-3{stroke:#000;stroke-miterlimit:10;}.cls-2{stroke-width:2px;}.cls-3{stroke-width:0.5px;}
</style>
</defs>
<title>connector_typ1</title>
<path class="cls-1" d="M12,12H36V36H12Z" />
<circle cx="15.79" cy="8.26" r="1.89" />
<circle cx="16.74" cy="14" r="1.18" />
<circle cx="7.26" cy="14" r="1.18" />
<circle cx="8.21" cy="8.26" r="1.89" />
<circle cx="12" cy="17.74" r="1.89" />
<circle class="cls-2" cx="12" cy="12.05" r="9" />
<rect x="10.58" y="21.05" width="2.84" height="1.89" />
<line class="cls-3" x1="10.5" y1="1" x2="13.5" y2="1" />
<polygon points="13.5 0.4 13.5 2.5 15.5 3.5 14.5 0.5 13.5 0.4" />
<polygon points="10.5 0.4 10.5 2.5 8.5 3.5 9.5 0.5 10.5 0.4" />
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 2
versionName "0.0.2"
versionCode 3
versionName "0.0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -62,13 +62,14 @@ dependencies {
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.4"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.core:core:1.3.0-rc01'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.google.maps.android:android-maps-utils:0.5'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:c2dcf0dc'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:73dd449f6f'
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'
@@ -90,6 +91,12 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.2.5"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'

View File

@@ -13,6 +13,7 @@ import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.storage.PreferenceDataSource
const val REQUEST_LOCATION_PERMISSION = 1
@@ -26,6 +27,7 @@ class MapsActivity : AppCompatActivity() {
private lateinit var navController: NavController
lateinit var appBarConfiguration: AppBarConfiguration
var fragmentCallback: FragmentCallback? = null
private lateinit var prefs: PreferenceDataSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -42,6 +44,8 @@ class MapsActivity : AppCompatActivity() {
findViewById<DrawerLayout>(R.id.drawer_layout)
)
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
prefs = PreferenceDataSource(this)
}
override fun onBackPressed() {
@@ -56,11 +60,11 @@ class MapsActivity : AppCompatActivity() {
// google maps navigation
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
val pm = packageManager
if (intent.resolveActivity(pm) != null) {
if (intent.resolveActivity(pm) != null && prefs.navigateUseMaps) {
startActivity(intent);
} else {
// fallback: generic geo intent
intent.data = Uri.parse("geo:${coord.lat},${coord.lng}")
intent.data = Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
if (intent.resolveActivity(pm) != null) {
startActivity(intent);
} else {
@@ -78,4 +82,12 @@ class MapsActivity : AppCompatActivity() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
}
fun shareUrl(url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(intent)
}
}

View File

@@ -13,6 +13,7 @@ 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.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean;
@@ -29,15 +30,15 @@ abstract class DataBindingAdapter<T : Equatable>() :
}
override fun onBindViewHolder(holder: ViewHolder<T>, position: Int) =
holder.bind(getItem(position))
bind(holder, getItem(position))
class ViewHolder<T>(private val binding: ViewDataBinding) :
class ViewHolder<T>(val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
}
fun bind(item: T) {
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
open fun bind(holder: ViewHolder<T>, item: T) {
holder.binding.setVariable(BR.item, item)
holder.binding.executePendingBindings()
}
class DiffCallback<T : Equatable> : DiffUtil.ItemCallback<T>() {
@@ -70,7 +71,8 @@ class DetailAdapter : DataBindingAdapter<DetailAdapter.Detail>() {
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null
val detailText: CharSequence? = null,
val links: Boolean = true
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_detail
@@ -109,6 +111,25 @@ fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail>
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
else null
else null,
DetailAdapter.Detail(
R.drawable.ic_location,
R.string.coordinates,
loc.coordinates.formatDMS(),
loc.coordinates.formatDecimal(),
false
)
)
}
class FavoritesAdapter(val vm: FavoritesViewModel) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
}

View File

@@ -38,13 +38,17 @@ suspend fun Call.await(): Response {
const val earthRadiusKm: Double = 6372.8
/**
* Calculates the distance between two points on Earth in meters.
* Latitude and longitude should be given in degrees.
*/
fun distanceBetween(
startLatitude: Double, startLongitude: Double,
endLatitude: Double, endLongitude: Double
): Double {
// see https://rosettacode.org/wiki/Haversine_formula#Java
val dLat = Math.toRadians(endLatitude - startLatitude);
val dLon = Math.toRadians(endLongitude - endLongitude);
val dLon = Math.toRadians(endLongitude - startLongitude);
val originLat = Math.toRadians(startLatitude);
val destinationLat = Math.toRadians(endLatitude);

View File

@@ -1,11 +1,15 @@
package net.vonforst.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.HttpException
import java.io.IOException
import java.util.concurrent.TimeUnit
@@ -14,6 +18,8 @@ interface AvailabilityDetector {
}
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
protected val radius = 150 // max radius in meters
protected suspend fun httpGet(url: String): String {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).await()
@@ -46,35 +52,55 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
return filter.getOrNull(0)
}
companion object {
internal fun matchChargepoints(
connectors: Map<Long, Pair<Double, String>>,
chargepoints: List<Chargepoint>
): Map<Chargepoint, Set<Long>> {
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val geTypes = chargepoints.map { it.type }.distinct().toSet()
if (types != geTypes) throw AvailabilityDetectorException("chargepoints do not match")
return types.flatMap { type ->
// find connectors of this type
val connsOfType = connectors.filter { it.value.second == type }
// find powers this connector is available as
val powers = connsOfType.map { it.value.first }.distinct().sorted()
// find corresponding powers in GE data
val gePowers =
chargepoints.filter { it.type == type }.map { it.power }.distinct().sorted()
protected fun matchChargepoints(
connectors: Map<Long, Pair<Double, String>>,
chargepoints: List<Chargepoint>
): Map<Chargepoint, Set<Long>> {
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val geTypes = chargepoints.map { it.type }.distinct().toSet()
if (types != geTypes) throw AvailabilityDetectorException("chargepoints do not match")
return types.flatMap { type ->
// find connectors of this type
val connsOfType = connectors.filter { it.value.second == type }
// find powers this connector is available as
val powers = connsOfType.map { it.value.first }.distinct().sorted()
// find corresponding powers in GE data
val gePowers =
chargepoints.filter { it.type == type }.map { it.power }.distinct().sorted()
// if the distinct number of powers is the same, try to match.
if (powers.size == gePowers.size) {
gePowers.zip(powers).map { (gePower, power) ->
val chargepoint = chargepoints.find { it.type == type && it.power == gePower }!!
val ids = connsOfType.filter { it.value.first == power }.keys
chargepoint to ids
// if the distinct number of powers is the same, try to match.
if (powers.size == gePowers.size) {
gePowers.zip(powers).map { (gePower, power) ->
val chargepoint =
chargepoints.find { it.type == type && it.power == gePower }!!
val ids = connsOfType.filter { it.value.first == power }.keys
if (chargepoint.count != ids.size) {
throw AvailabilityDetectorException("chargepoints do not match")
}
chargepoint to ids
}
} else if (powers.size == 1 && gePowers.size == 2
&& chargepoints.sumBy { it.count } == connsOfType.size
) {
// special case: dual charger(s) with load balancing
// GoingElectric shows 2 different powers, NewMotion just one
val allIds = connsOfType.keys.toList()
var i = 0
gePowers.map { gePower ->
val chargepoint =
chargepoints.find { it.type == type && it.power == gePower }!!
val ids = allIds.subList(i, i + chargepoint.count).toSet()
i += chargepoint.count
chargepoint to ids
}
// TODO: this will not necessarily first fill up the higher-power chargepoint
} else {
throw AvailabilityDetectorException("chargepoints do not match")
}
} else {
throw AvailabilityDetectorException("chargepoints do not match")
}
}.toMap()
}.toMap()
}
}
}
@@ -104,4 +130,26 @@ val availabilityDetectors = listOf(
okhttp,
"6336fe713f2eb7fa04b97ff6651b76f8"
) // SW Kiel*/
)
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
}
}
}
return value ?: Resource.error(null, null)
}

View File

@@ -8,8 +8,6 @@ import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException
private const val radius = 200 // max radius in meters
class ChargecloudAvailabilityDetector(
client: OkHttpClient,
private val operatorId: String

View File

@@ -99,6 +99,16 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
distanceBetween(marker.coordinates.latitude, marker.coordinates.longitude, lat, lng)
} ?: throw AvailabilityDetectorException("no candidates found.")
if (distanceBetween(
nearest.coordinates.latitude,
nearest.coordinates.longitude,
lat,
lng
) > radius
) {
throw AvailabilityDetectorException("no candidates found")
}
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
@@ -129,7 +139,9 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType) {
"Type3" -> Chargepoint.TYPE_3
"Type2" -> Chargepoint.TYPE_2
"Type1" -> Chargepoint.TYPE_1
"Domestic" -> Chargepoint.SCHUKO
"Type2Combo" -> Chargepoint.CCS
"TepcoCHAdeMO" -> Chargepoint.CHADEMO

View File

@@ -3,6 +3,9 @@ package net.vonforst.evmap.api.goingelectric
import android.content.Context
import android.os.Parcelable
import androidx.core.text.HtmlCompat
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parcelize
@@ -11,6 +14,9 @@ import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
@JsonClass(generateAdapter = true)
data class ChargepointList(
@@ -21,11 +27,12 @@ data class ChargepointList(
sealed class ChargepointListItem
@JsonClass(generateAdapter = true)
@Entity
data class ChargeLocation(
@Json(name = "ge_id") val id: Long,
@Json(name = "ge_id") @PrimaryKey val id: Long,
val name: String,
val coordinates: Coordinate,
val address: Address,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
val chargepoints: List<Chargepoint>,
@JsonObjectOrFalse val network: String?,
val url: String,
@@ -38,9 +45,9 @@ data class ChargeLocation(
@JsonObjectOrFalse @Json(name = "location_description") val locationDescription: String?,
val photos: List<ChargerPhoto>?,
//val chargecards: Boolean?
val openinghours: OpeningHours?,
val cost: Cost?
) : ChargepointListItem() {
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?
) : ChargepointListItem(), Equatable {
val maxPower: Double
get() {
return chargepoints.map { it.power }.max() ?: 0.0
@@ -75,7 +82,7 @@ data class Cost(
data class OpeningHours(
@Json(name = "24/7") val twentyfourSeven: Boolean,
@JsonObjectOrFalse val description: String?,
val days: OpeningHoursDays?
@Embedded val days: OpeningHoursDays?
) {
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
@@ -114,14 +121,14 @@ data class OpeningHours(
@JsonClass(generateAdapter = true)
data class OpeningHoursDays(
val monday: Hours,
val tuesday: Hours,
val wednesday: Hours,
val thursday: Hours,
val friday: Hours,
val saturday: Hours,
val sunday: Hours,
val holiday: Hours
@Embedded(prefix = "mo") val monday: Hours,
@Embedded(prefix = "tu") val tuesday: Hours,
@Embedded(prefix = "we") val wednesday: Hours,
@Embedded(prefix = "th") val thursday: Hours,
@Embedded(prefix = "fr") val friday: Hours,
@Embedded(prefix = "sa") val saturday: Hours,
@Embedded(prefix = "su") val sunday: Hours,
@Embedded(prefix = "ho") val holiday: Hours
) {
fun getHoursForDate(date: LocalDate): Hours {
// TODO: check for holidays
@@ -155,7 +162,28 @@ data class ChargeLocationCluster(
) : ChargepointListItem()
@JsonClass(generateAdapter = true)
data class Coordinate(val lat: Double, val lng: Double)
data class Coordinate(val lat: Double, val lng: Double) {
fun formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun formatDecimal(): String {
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
}
}
@JsonClass(generateAdapter = true)
data class Address(
@@ -181,7 +209,9 @@ data class Chargepoint(val type: String, val power: Double, val count: Int) : Eq
}
companion object {
const val TYPE_1 = "Typ1"
const val TYPE_2 = "Typ2"
const val TYPE_3 = "Typ3"
const val CCS = "CCS"
const val SCHUKO = "Schuko"
const val CHADEMO = "CHAdeMO"

View File

@@ -0,0 +1,94 @@
package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
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.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.model.LatLng
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.FavoritesAdapter
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment() {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var fusedLocationClient: FusedLocationProviderClient
private val vm: FavoritesViewModel by viewModels(factoryProducer = {
viewModelFactory {
FavoritesViewModel(
requireActivity().application,
getString(R.string.goingelectric_key)
)
}
})
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_favorites, container, false
)
binding.lifecycleOwner = this
binding.vm = vm
fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext())
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.favsList.apply {
adapter = FavoritesAdapter(vm)
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(
context, LinearLayoutManager.VERTICAL
)
)
}
vm.favorites.observe(viewLifecycleOwner, Observer {
print(it.toString())
})
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
vm.location.value = LatLng(location.latitude, location.longitude)
}
}
}
}

View File

@@ -3,12 +3,15 @@ package net.vonforst.evmap.fragment
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Bundle
import android.view.*
import android.view.inputmethod.InputMethodManager
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -33,6 +36,7 @@ import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import com.google.android.material.snackbar.Snackbar
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import kotlinx.android.synthetic.main.fragment_map.*
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
@@ -44,7 +48,10 @@ import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.ui.*
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
@@ -55,17 +62,25 @@ const val REQUEST_AUTOCOMPLETE = 2
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels(factoryProducer = {
viewModelFactory { MapViewModel(getString(R.string.goingelectric_key)) }
viewModelFactory {
MapViewModel(
requireActivity().application,
getString(R.string.goingelectric_key)
)
}
})
private val galleryVm: GalleryViewModel by activityViewModels()
private var map: GoogleMap? = null
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 clusterMarkers: List<Marker> = emptyList()
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
private lateinit var animator: MarkerAnimator
private lateinit var favToggle: MenuItem
override fun onCreateView(
inflater: LayoutInflater,
@@ -79,10 +94,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
chargerIconGenerator = ChargerIconGenerator(requireContext())
animator = MarkerAnimator(chargerIconGenerator)
setHasOptionsMenu(true)
postponeEnterTransition()
binding.root.setOnApplyWindowInsetsListener { v, insets ->
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.systemWindowInsetTop
}
insets
}
return binding.root
}
@@ -90,6 +113,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
setupObservers()
setupClickListeners()
@@ -145,6 +172,39 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.build(requireContext())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, 0)
}
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
}
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_fav -> {
toggleFavorite()
true
}
R.id.menu_share -> {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.shareUrl("https:${charger.url}")
}
true
}
else -> false
}
}
}
private fun toggleFavorite() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
if (favs.find { it.id == charger.id } != null) {
vm.deleteFavorite(charger)
} else {
vm.insertFavorite(charger)
}
}
@@ -165,6 +225,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
}
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
} else {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
}
@@ -173,6 +235,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val chargepoints = it.data
if (chargepoints != null) updateMap(chargepoints)
})
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
})
}
private fun updateFavoriteToggle() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
if (favs.find { it.id == charger.id } != null) {
favToggle.setIcon(R.drawable.ic_fav)
} else {
favToggle.setIcon(R.drawable.ic_fav_no)
}
}
private fun setupAdapters() {
@@ -312,8 +387,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) ==
PackageManager.PERMISSION_GRANTED
) == PackageManager.PERMISSION_GRANTED
}
private fun updateMap(chargepoints: List<ChargepointListItem>) {
@@ -328,7 +402,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (!chargepointIds.contains(it.value.id)) {
val tint = getMarkerTint(it.value)
if (it.key.isVisible) {
animateMarkerDisappear(it.key, tint, chargerIconGenerator)
animator.animateMarkerDisappear(it.key, tint)
} else {
it.key.remove()
}
@@ -348,7 +422,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
chargerIconGenerator.getBitmapDescriptor(tint)
)
)
animateMarkerAppear(marker, tint, chargerIconGenerator)
animator.animateMarkerAppear(marker, tint)
marker to charger
}.toMap()

View File

@@ -0,0 +1,36 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
class SettingsFragment : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
else -> super.onPreferenceTreeClick(preference)
}
}
}

View File

@@ -0,0 +1,17 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.api.goingelectric.ChargeLocation
@Dao
interface ChargeLocationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg locations: ChargeLocation)
@Delete
suspend fun delete(vararg locations: ChargeLocation)
@Query("SELECT * FROM chargelocation")
fun getAllChargeLocations(): LiveData<List<ChargeLocation>>
}

View File

@@ -0,0 +1,26 @@
package net.vonforst.evmap.storage
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import net.vonforst.evmap.api.goingelectric.ChargeLocation
@Database(entities = [ChargeLocation::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
companion object {
private lateinit var context: Context
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db").build()
}
fun getInstance(context: Context): AppDatabase {
this.context = context.applicationContext
return database
}
}
}

View File

@@ -0,0 +1,14 @@
package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
class PreferenceDataSource(context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
var navigateUseMaps: Boolean
get() = sp.getBoolean("navigate_use_maps", true)
set(value) {
sp.edit().putBoolean("navigate_use_maps", value).apply()
}
}

View File

@@ -0,0 +1,52 @@
package net.vonforst.evmap.storage
import androidx.room.TypeConverter
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.LocalTime
class Converters {
val moshi = Moshi.Builder().build()
private val chargepointListAdapter by lazy {
val type = Types.newParameterizedType(List::class.java, Chargepoint::class.java)
moshi.adapter<List<Chargepoint>>(type)
}
private val chargerPhotoListAdapter by lazy {
val type = Types.newParameterizedType(List::class.java, ChargerPhoto::class.java)
moshi.adapter<List<ChargerPhoto>>(type)
}
@TypeConverter
fun fromChargepointList(value: List<Chargepoint>?): String {
return chargepointListAdapter.toJson(value)
}
@TypeConverter
fun toChargepointList(value: String): List<Chargepoint>? {
return chargepointListAdapter.fromJson(value)
}
@TypeConverter
fun fromChargerPhotoList(value: List<ChargerPhoto>?): String {
return chargerPhotoListAdapter.toJson(value)
}
@TypeConverter
fun toChargerPhotoList(value: String): List<ChargerPhoto>? {
return chargerPhotoListAdapter.fromJson(value)
}
@TypeConverter
fun fromLocalTime(value: LocalTime?): String? {
return value?.toString()
}
@TypeConverter
fun toLocalTime(value: String?): LocalTime? {
return value.let {
LocalTime.parse(it)
}
}
}

View File

@@ -64,6 +64,7 @@ fun getConnectorItem(view: ImageView, type: String) {
Chargepoint.TYPE_2 -> R.drawable.ic_connector_typ2
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
// TODO: add other connectors
else -> 0
}

View File

@@ -16,47 +16,65 @@ fun getMarkerTint(charger: ChargeLocation): Int = when {
else -> R.color.charger_low
}
fun animateMarkerAppear(
marker: Marker,
tint: Int,
gen: ChargerIconGenerator
) {
ValueAnimator.ofInt(0, 20).apply {
duration = 250
interpolator = LinearOutSlowInInterpolator()
addUpdateListener { animationState ->
if (!marker.isVisible) {
cancel()
return@addUpdateListener
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
)
}
}.start()
}
class MarkerAnimator(val gen: ChargerIconGenerator) {
val animatingMarkers = hashMapOf<Marker, ValueAnimator>()
fun animateMarkerDisappear(
marker: Marker,
tint: Int,
gen: ChargerIconGenerator
) {
ValueAnimator.ofInt(20, 0).apply {
duration = 200
interpolator = FastOutLinearInInterpolator()
addUpdateListener { animationState ->
if (!marker.isVisible) {
cancel()
return@addUpdateListener
fun animateMarkerAppear(
marker: Marker,
tint: Int
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
val anim = ValueAnimator.ofInt(0, 20).apply {
duration = 250
interpolator = LinearOutSlowInInterpolator()
addUpdateListener { animationState ->
if (!marker.isVisible) {
cancel()
animatingMarkers.remove(marker)
return@addUpdateListener
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
)
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
)
addListener(onEnd = {
animatingMarkers.remove(marker)
})
}
addListener(onEnd = {
marker.remove()
})
}.start()
animatingMarkers[marker] = anim
anim.start()
}
fun animateMarkerDisappear(
marker: Marker,
tint: Int
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
val anim = ValueAnimator.ofInt(20, 0).apply {
duration = 200
interpolator = FastOutLinearInInterpolator()
addUpdateListener { animationState ->
if (!marker.isVisible) {
cancel()
animatingMarkers.remove(marker)
return@addUpdateListener
}
val scale = animationState.animatedValue as Int
marker.setIcon(
gen.getBitmapDescriptor(tint, scale = scale)
)
}
addListener(onEnd = {
animatingMarkers.remove(marker)
marker.remove()
})
}
animatingMarkers[marker] = anim
anim.start()
}
}

View File

@@ -0,0 +1,110 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.model.LatLng
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
class FavoritesViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var db = AppDatabase.getInstance(application)
val favorites: LiveData<List<ChargeLocation>> by lazy {
db.chargeLocationsDao().getAllChargeLocations()
}
val location: MutableLiveData<LatLng> by lazy {
MutableLiveData<LatLng>()
}
val availability: MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>> by lazy {
MediatorLiveData<Map<Long, Resource<ChargeLocationStatus>>>().apply {
addSource(favorites) { chargers ->
if (chargers != null) {
viewModelScope.launch {
val data = hashMapOf<Long, Resource<ChargeLocationStatus>>()
chargers.forEach { charger ->
data[charger.id] = Resource.loading(null)
}
availability.value = data
chargers.map { charger ->
async {
data[charger.id] = getAvailability(charger)
availability.value = data
}
}.awaitAll()
}
} else {
value = null
}
}
}
}
val listData: MediatorLiveData<List<FavoritesListItem>> by lazy {
MediatorLiveData<List<FavoritesListItem>>().apply {
val callback = { _: Any ->
listData.value = favorites.value?.map { charger ->
FavoritesListItem(
charger,
totalAvailable(charger.id),
charger.chargepoints.sumBy { it.count },
location.value.let { loc ->
if (loc == null) null else {
distanceBetween(
loc.latitude,
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
}
})
}
}
addSource(favorites, callback)
addSource(location, callback)
addSource(availability, callback)
}
}
data class FavoritesListItem(
val charger: ChargeLocation,
val available: Resource<Int>,
val total: Int,
val distance: Double?
) : Equatable
private fun totalAvailable(id: Long): Resource<Int> {
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 })
}
}
fun insertFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().insert(charger)
}
}
fun deleteFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().delete(charger)
}
}
}

View File

@@ -1,31 +1,25 @@
package net.vonforst.evmap.viewmodel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.model.LatLngBounds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.availabilityDetectors
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
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 retrofit2.Call
import retrofit2.Callback
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
class MapViewModel(geApiKey: String) : ViewModel() {
private var api: GoingElectricApi =
GoingElectricApi.create(geApiKey)
class MapViewModel(application: Application, geApiKey: String) : AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var db = AppDatabase.getInstance(application)
val bottomSheetState: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
@@ -87,6 +81,22 @@ class MapViewModel(geApiKey: String) : ViewModel() {
MutableLiveData<Boolean>()
}
val favorites: LiveData<List<ChargeLocation>> by lazy {
db.chargeLocationsDao().getAllChargeLocations()
}
fun insertFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().insert(charger)
}
}
fun deleteFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().delete(charger)
}
}
private fun loadChargepoints(mapPosition: MapPosition) {
chargepoints.value = Resource.loading(chargepoints.value?.data)
val bounds = mapPosition.bounds
@@ -118,25 +128,7 @@ class MapViewModel(geApiKey: String) : ViewModel() {
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
try {
value = Resource.success(ad.getAvailability(charger))
break
} catch (e: IOException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: HttpException) {
value = Resource.error(e.message, null)
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
value = Resource.error(e.message, null)
e.printStackTrace()
}
}
}
availability.value = value
availability.value = getAvailability(charger)
}
private fun loadChargerDetails(charger: ChargeLocation) {

View File

@@ -0,0 +1,40 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.79,8.26m-1.89,0a1.89,1.89 0,1 1,3.78 0a1.89,1.89 0,1 1,-3.78 0" />
<path
android:fillColor="#FF000000"
android:pathData="M16.74,14m-1.18,0a1.18,1.18 0,1 1,2.36 0a1.18,1.18 0,1 1,-2.36 0" />
<path
android:fillColor="#FF000000"
android:pathData="M7.26,14m-1.18,0a1.18,1.18 0,1 1,2.36 0a1.18,1.18 0,1 1,-2.36 0" />
<path
android:fillColor="#FF000000"
android:pathData="M8.21,8.26m-1.89,0a1.89,1.89 0,1 1,3.78 0a1.89,1.89 0,1 1,-3.78 0" />
<path
android:fillColor="#FF000000"
android:pathData="M12,17.74m-1.89,0a1.89,1.89 0,1 1,3.78 0a1.89,1.89 0,1 1,-3.78 0" />
<path
android:pathData="M12,12.05m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000" />
<path
android:fillColor="#FF000000"
android:pathData="M10.58,21.05h2.84v1.89h-2.84z" />
<path
android:pathData="M10.5,1L13.5,1"
android:strokeWidth="0.5"
android:fillColor="#00000000"
android:strokeColor="#000" />
<path
android:fillColor="#FF000000"
android:pathData="M13.5,0.4l0,2.1l2,1l-1,-3l-1,-0.1z" />
<path
android:fillColor="#FF000000"
android:pathData="M10.5,0.4l0,2.1l-2,1l1,-3l1,-0.1z" />
</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="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View File

@@ -30,7 +30,7 @@
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="6dp">
<androidx.constraintlayout.widget.ConstraintLayout

View File

@@ -0,0 +1,38 @@
<?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.FavoritesViewModel" />
<variable
name="vm"
type="FavoritesViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize" />
</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>
</layout>

View File

@@ -27,13 +27,13 @@
tools:context=".MapsActivity" />
<FrameLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_behavior="@string/ScrollingAppBarLayoutBehavior">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
@@ -72,8 +72,9 @@
</FrameLayout>
<FrameLayout
android:id="@+id/gallery_container"
android:layout_width="match_parent"
android:layout_height="@dimen/gallery_height"
android:layout_height="@dimen/gallery_height_with_margin"
android:background="?android:colorBackground"
android:fitsSystemWindows="true"
app:layout_behavior="@string/BackDropBottomSheetBehavior">
@@ -81,7 +82,7 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/gallery"
android:layout_width="match_parent"
android:layout_height="@dimen/gallery_height"
android:layout_height="match_parent"
app:data="@{vm.charger.data.photos}" />
<ImageView
@@ -141,10 +142,11 @@
app:layout_anchorGravity="top|right|end"
app:layout_behavior="@string/ScrollAwareFABBehavior" />
<!--<com.mahc.custombottomsheetbehavior.MergedAppBarLayout
android:id="@+id/mergedappbarlayout"
<com.mahc.custombottomsheetbehavior.MergedAppBarLayout
android:id="@+id/detail_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/MergedAppBarLayoutBehavior"/>-->
app:layout_behavior="@string/MergedAppBarLayoutBehavior"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.text.util.Linkify" />
<variable
name="item"
@@ -54,7 +55,7 @@
android:layout_marginBottom="14dp"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:autoLink="phone|web"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -0,0 +1,90 @@
<?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.api.UtilsKt" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<variable
name="item"
type="net.vonforst.evmap.viewmodel.FavoritesViewModel.FavoritesListItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.charger.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
<TextView
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.address.toString()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView15"
tools:text="Beispielstraße 10, 12345 Berlin" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.formatChargepoints()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/distance_format(item.distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9999,9 km" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
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:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"
app:goneUnless="@{item.available.status == Status.SUCCESS}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:backgroundTint="@color/available"
tools:text="80/99" />
<ProgressBar
android:id="@+id/progressBar4"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
app:goneUnless="@{item.available.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_share"
android:icon="@drawable/ic_share"
android:title="@string/share"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_fav"
android:icon="@drawable/ic_fav_no"
android:title="@string/fav_add"
app:showAsAction="ifRoom" />
</menu>

View File

@@ -12,7 +12,7 @@
tools:layout="@layout/fragment_map">
<action
android:id="@+id/action_map_to_galleryFragment"
app:destination="@id/galleryFragment"
app:destination="@id/gallery"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
@@ -21,9 +21,21 @@
<fragment
android:id="@+id/about"
android:name="net.vonforst.evmap.fragment.AboutFragment"
android:label="@string/about" />
android:label="@string/about"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/galleryFragment"
android:id="@+id/settings"
android:name="net.vonforst.evmap.fragment.SettingsFragment"
android:label="@string/settings"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/gallery"
android:name="net.vonforst.evmap.fragment.GalleryFragment"
android:label="GalleryFragment"></fragment>
android:label="GalleryFragment"
tools:layout="@layout/fragment_gallery" />
<fragment
android:id="@+id/favs"
android:name="net.vonforst.evmap.fragment.FavoritesFragment"
android:label="@string/menu_favs"
tools:layout="@layout/fragment_favorites" />
</navigation>

View File

@@ -36,4 +36,12 @@
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="other">Sonstiges</string>
<string name="privacy">Datenschutzerklärung</string>
<string name="fav_add">Zu Favoriten hinzufügen</string>
<string name="fav_remove">Aus Favoriten entfernen</string>
<string name="distance_format">%.1f km</string>
<string name="pref_navigate_use_maps">Navigation sofort starten</string>
<string name="pref_navigate_use_maps_on">Navigationsbutton startet Navigation direkt</string>
<string name="pref_navigate_use_maps_off">Navigationsbutton startet Karten-App mit Position der Ladesäule</string>
<string name="coordinates">Koordinaten</string>
<string name="share">Teilen</string>
</resources>

View File

@@ -2,4 +2,5 @@
<resources>
<dimen name="peek_height">72dp</dimen>
<dimen name="gallery_height">200dp</dimen>
<dimen name="gallery_height_with_margin">208dp</dimen>
</resources>

View File

@@ -35,4 +35,12 @@
<string name="copyright_summary">©2020 Johan von Forstner</string>
<string name="other">Other</string>
<string name="privacy">Privacy Notice</string>
<string name="fav_add">Add to favorites</string>
<string name="fav_remove">Remove from favorites</string>
<string name="distance_format">%.1f km</string>
<string name="pref_navigate_use_maps">Start navigation immediately</string>
<string name="pref_navigate_use_maps_on">Navigation button starts navigation immediately</string>
<string name="pref_navigate_use_maps_off">Navigation button launches maps app with charger location</string>
<string name="coordinates">Coordinates</string>
<string name="share">Share</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="@string/settings">
<CheckBoxPreference
android:key="navigate_use_maps"
android:title="@string/pref_navigate_use_maps"
android:summaryOn="@string/pref_navigate_use_maps_on"
android:summaryOff="@string/pref_navigate_use_maps_off"
android:defaultValue="true" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,12 @@
package net.vonforst.evmap.api
import org.junit.Assert.assertEquals
import org.junit.Test
class UtilsTest {
@Test
fun testDistanceBetween() {
assertEquals(129412.71, distanceBetween(54.0, 9.0, 53.0, 8.0), 0.01)
}
}

View File

@@ -0,0 +1,128 @@
package net.vonforst.evmap.api.availability
import net.vonforst.evmap.api.goingelectric.Chargepoint
import org.junit.Assert.assertEquals
import org.junit.Test
class AvailabilityDetectorTest {
@Test
fun testMatchChargepointsSingleCorrect() {
// single charger with 2 22kW chargepoints
val chargepoints = listOf(Chargepoint("Typ2", 22.0, 2))
// correct data in NewMotion
assertEquals(
mapOf(chargepoints[0] to setOf(0L, 1L)),
BaseAvailabilityDetector.matchChargepoints(
mapOf(0L to (22.0 to "Typ2"), 1L to (22.0 to "Typ2")),
chargepoints
)
)
}
@Test
fun testMatchChargepointsSingleWrongPower() {
// single charger with 2 22kW chargepoints
val chargepoints = listOf(Chargepoint("Typ2", 22.0, 2))
// wrong power in NewMotion
assertEquals(
mapOf(chargepoints[0] to setOf(0L, 1L)),
BaseAvailabilityDetector.matchChargepoints(
mapOf(0L to (27.0 to "Typ2"), 1L to (27.0 to "Typ2")),
chargepoints
)
)
}
@Test(expected = AvailabilityDetectorException::class)
fun testMatchChargepointsSingleWrong() {
// single charger with 2 22kW chargepoints
val chargepoints = listOf(Chargepoint("Typ2", 22.0, 2))
// non-matching data in NewMotion
BaseAvailabilityDetector.matchChargepoints(
mapOf(0L to (27.0 to "Typ2"), 1L to (27.0 to "Typ2"), 2L to (50.0 to "CCS")),
chargepoints
)
}
@Test
fun testMatchChargepointsComplex() {
// charger with many different connectors
val chargepoints = listOf(
Chargepoint("Typ2", 43.0, 1),
Chargepoint("CCS", 50.0, 1),
Chargepoint("CHAdeMO", 50.0, 2),
Chargepoint("CCS", 160.0, 1),
Chargepoint("CCS", 320.0, 2)
)
// partly wrong power in NewMotion
assertEquals(
mapOf(
chargepoints[0] to setOf(6L),
chargepoints[1] to setOf(4L),
chargepoints[2] to setOf(0L, 5L),
chargepoints[3] to setOf(2L),
chargepoints[4] to setOf(1L, 3L)
),
BaseAvailabilityDetector.matchChargepoints(
mapOf(
// CHAdeMO + CCS HPC
0L to (50.0 to "CHAdeMO"),
1L to (200.0 to "CCS"),
// dual CCS HPC
2L to (80.0 to "CCS"),
3L to (200.0 to "CCS"),
// 50kW triple charger
4L to (50.0 to "CCS"),
5L to (50.0 to "CHAdeMO"),
6L to (43.0 to "Typ2")
),
chargepoints
)
)
}
@Test
fun testMatchChargepointsDifferentPower() {
// single charger with 1 22kW and 1 11kW chargepoint (common when load balancing is applied)
val chargepoints = listOf(
Chargepoint("Typ2", 22.0, 1),
Chargepoint("Typ2", 11.0, 1)
)
// both have 27 kW power in NewMotion
assertEquals(
mapOf(chargepoints[1] to setOf(0L), chargepoints[0] to setOf(1L)),
BaseAvailabilityDetector.matchChargepoints(
mapOf(0L to (27.0 to "Typ2"), 1L to (27.0 to "Typ2")),
chargepoints
)
)
}
@Test
fun testMatchChargepointsDifferentPower2() {
// two chargers with 1 22kW and 1 11kW chargepoint (common when load balancing is applied)
val chargepoints = listOf(
Chargepoint("Typ2", 22.0, 2),
Chargepoint("Typ2", 11.0, 2)
)
// both have 27 kW power in NewMotion
assertEquals(
mapOf(chargepoints[1] to setOf(0L, 1L), chargepoints[0] to setOf(2L, 3L)),
BaseAvailabilityDetector.matchChargepoints(
mapOf(
0L to (27.0 to "Typ2"),
1L to (27.0 to "Typ2"),
2L to (27.0 to "Typ2"),
3L to (27.0 to "Typ2")
),
chargepoints
)
)
}
}

View File

@@ -1,8 +1,7 @@
package net.vonforst.evmap
package net.vonforst.evmap.api.availability
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.api.availability.NewMotionAvailabilityDetector
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import okhttp3.OkHttpClient