Compare commits

..

37 Commits
0.0.2 ... 0.0.4

Author SHA1 Message Date
Johan von Forstner
c59ec9e895 version 0.0.4 2020-05-07 08:21:32 +02:00
Johan von Forstner
fd288e653a implement some additional filters (#9)
now available: free charging, free parking, minimum power
2020-05-07 08:19:46 +02:00
Johan von Forstner
dec7e6bdc9 build APK on Travis CI and deploy to GitHub 2020-05-03 20:16:35 +02:00
Johan von Forstner
6276bef1e0 set tools.listitem for nicer display in preview 2020-04-28 20:02:47 +02:00
Johan von Forstner
5c72ee718b working implementation for first filter (free charging) #9 2020-04-28 19:38:10 +02:00
Johan von Forstner
810338ba38 don't use DialogFragment for FilterFramgent 2020-04-25 20:20:19 +02:00
Johan von Forstner
53a9af8226 use the navigation component's OnBackPressedCallback instead of custom implementation 2020-04-25 19:59:57 +02:00
Johan von Forstner
e5dd0e19ab add FilterFragment 2020-04-25 19:43:48 +02:00
Johan von Forstner
78421ec79f improve gallery transition and fix crash 2020-04-24 20:37:03 +02:00
Johan von Forstner
12329f82b3 Merge Type2 sockets and plugs (fixes #11)
(they are not differentiable in the GoingElectric API)
2020-04-24 19:56:54 +02:00
Johan von Forstner
528790b570 NewMotion: support type "Unspecified" 2020-04-24 19:40:39 +02:00
Johan von Forstner
89af31c684 fix NullPointerException 2020-04-23 14:02:48 +02:00
Johan von Forstner
6c8efed96a README.md improvements
move screenshots into one line
decrease icon size
2020-04-23 13:18:18 +02:00
Johan von Forstner
6b8e87a6c7 add more content to README.md 2020-04-23 13:16:27 +02:00
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
54 changed files with 2030 additions and 232 deletions

View File

@@ -8,8 +8,11 @@ env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
before_install:
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
script:
- "./gradlew lintDebug testDebugUnitTest"
- "./gradlew assembleRelease"
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
@@ -18,3 +21,11 @@ cache:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
deploy:
provider: releases
api_key:
secure: B+V5Fz8k9HbpecyMjpJuLr8aVBrdwtDBDkQh4YQ8nu+Da4AiYwEJZseWXhOWs+oms0gNen9bBxsakQQKu7GKYDs8gIXZZtANWsc0gse8xo+cYT7NqEM3jP6mM3ytAv7VNRX3N2cdL7xazELK3/5+mghfORAAdXXYKUFGG5eTKoML8zgdPVN8E9QFqiusLXqoKhxOMCSE4NS+Di7CGlUmnidRTWg6yxhE085zljmYv2owS0NRbr5a4/zW6Z9xZPALGAqsOvIvpZHuOC2s0eMJWMmYGkK/Ws/LAVxfj4U+YkFp9hlZC0zEg/JoS19Gf57QmEu+vsoQ3uOBYBFv9NPI/R9kVH6o0hcOxId3J0u+ewSGWuceGLRpizXuMxKIvLTS5j6GWkxdSieWjwh/OuVB+ciAHNM31B7GP4FWnfz0ZaEVxI/tPenNipZdl9oXdyyBQQ00vPlYp0jT80XhaMh5rDwWMUPaEjRafvymcNyqZ0iVOr0rq1CbdT92STMSmA1U3/rmhtCMD5IGD0b+gQl+VpPKe1QXViYftVxCGL+s4ke4DUZD7HR20fGs8zu61Elnwci1HufbetKFL5TmxoKSLkWFSkzrtBaJnEruZIxhNUMkUL2UPynaOcPNzLoumjHXrUb3m3s0yE4OFelmJ6mJfXswP38sS8kj3wB7R/gC4rw=
file: app/build/outputs/apk/release/app-release.apk
on:
repo: johan12345/EVMap
skip_cleanup: 'true'

View File

@@ -1,8 +1,48 @@
EVMap [![Build Status](https://travis-ci.org/johan12345/EVMap.svg?branch=master)](https://travis-ci.org/johan12345/EVMap)
=====
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/appicon.svg?sanitize=true" width=250 alt="Logo"/>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/appicon_cropped.svg?sanitize=true" width=80 alt="Logo"/>
Android app to access the goingelectric.de electric vehicle charging station directory
Android app to access the goingelectric.de electric vehicle charging station directory.
Work in progress
<a href="https://play.google.com/store/apps/details?id=net.vonforst.evmap" target="_blank">
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="100"/></a>
Features
--------
- [Material Design](https://material.io/)
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) directory
- Realtime availability information (beta)
- Search places
- Favorites list, also with availability information
- No ads, fully open source
- Compatible with Android 5.0 and above
Screenshots
-----------
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/01_main.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
The App is developed using Android Studio.
For testing the app, you need to obtain API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/)
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated). These APIs need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
```xml
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>
</resources>
```

BIN
_ci/keystore.jks.enc Normal file
View File

Binary file not shown.

40
_img/appicon_cropped.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 75.4 104" style="enable-background:new 0 0 75.4 104;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFB300;}
.st1{fill:#90A4AE;}
.st2{fill:#546E7A;}
.st3{fill:#00E676;}
.st4{fill:#FFFFFF;fill-opacity:0.2;}
.st5{fill:#3E2723;fill-opacity:0.2;}
.st6{opacity:0.45;enable-background:new ;}
</style>
<g>
<g>
<path class="st0"
d="M9.2,76.5L7.3,59.9l-2.9,0.3l1.9,16.6L9.2,76.5z M19.5,75.3l-1.9-16.6L14.7,59l1.9,16.6L19.5,75.3z" />
<path class="st1" d="M24.9,97.9c-0.9,1.1-1.6,1.8-1.7,1.9c-2.6,2.1-4.7,2.7-6.4,1.9c-3-1.5-2.8-7.1-2.7-7.7l2.1,0.1
c-0.1,1.6,0.2,5,1.6,5.7c0.8,0.4,2.2-0.1,4-1.6l0,0c0,0,5.8-5.8,4.6-10.4c-1.4-5.5,5-13.4,7.1-16.1l0.3-0.3l1.7,1.3l-0.3,0.4
c-6.5,8-7.2,12.1-6.7,14.2C29.5,91.3,26.8,95.6,24.9,97.9z" />
<path class="st1" d="M2.8,76.3l0.8,6.8l6.3,4.2l8.5-0.9l5.2-5.5l-0.8-6.8L2.8,76.3z" />
<g>
<path class="st2"
d="M18.3,86.4l-8.5,0.9l1.8,7.5l6.7-0.8V86.4L18.3,86.4z M24.4,68.4l0.7,6.2L0.7,77.4L0,71.2L24.4,68.4z" />
</g>
</g>
<g>
<g>
<path class="st3" d="M43.5,0C26,0,11.8,14.2,11.8,31.7c0,23.9,26.7,36.4,29.9,70.5c0.1,1,0.9,1.7,1.9,1.7s1.8-0.7,1.9-1.7
c3.2-34.1,29.9-46.6,29.9-70.5C75.2,14.1,61,0,43.5,0z" />
<path class="st4" d="M43.5,0.7c17.4,0,31.5,14,31.7,31.3c0-0.1,0-0.2,0-0.3C75.2,14.2,61,0,43.5,0S11.8,14.1,11.8,31.7
c0,0.1,0,0.2,0,0.3C12,14.7,26.1,0.7,43.5,0.7L43.5,0.7z" />
<path class="st5" d="M45.4,101.4c-0.1,1-0.9,1.7-1.9,1.7s-1.8-0.7-1.9-1.7c-3.1-34-29.6-46.5-29.8-70.1c0,0.2,0,0.3,0,0.5
c0,23.9,26.7,36.4,29.9,70.5c0.1,1,0.9,1.7,1.9,1.7s1.8-0.7,1.9-1.7c3.2-34.1,29.9-46.6,29.9-70.5c0-0.2,0-0.3,0-0.5
C75,54.9,48.5,67.4,45.4,101.4L45.4,101.4z" />
</g>
<path class="st6"
d="M36.2,16.2v19.2h5.2v15.7l12.2-21h-7l7-14C53.7,16.2,36.2,16.2,36.2,16.2z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

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,19 +13,33 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 2
versionName "0.0.2"
versionCode 4
versionName "0.0.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
def isRunningOnTravis = System.getenv("CI") == "true"
if (isRunningOnTravis) {
// configure keystore
signingConfigs.release.storeFile = file("../_ci/keystore.jks")
signingConfigs.release.storePassword = System.getenv("keystore_password")
signingConfigs.release.keyAlias = System.getenv("keystore_alias")
signingConfigs.release.keyPassword = System.getenv("keystore_alias_password")
}
compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility = JavaVersion.VERSION_1_8
@@ -62,13 +76,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'
@@ -81,7 +96,7 @@ dependencies {
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
// navigation library
def nav_version = "2.3.0-alpha04"
def nav_version = "2.3.0-alpha05"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -90,6 +105,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,19 +13,20 @@ 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
class MapsActivity : AppCompatActivity() {
interface FragmentCallback {
fun getRootView(): View
fun goBack(): Boolean
}
private var reenterState: Bundle? = null
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,11 +43,8 @@ class MapsActivity : AppCompatActivity() {
findViewById<DrawerLayout>(R.id.drawer_layout)
)
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
}
override fun onBackPressed() {
val didGoBack = fragmentCallback?.goBack() ?: false
if (!didGoBack) super.onBackPressed()
prefs = PreferenceDataSource(this)
}
fun navigateTo(charger: ChargeLocation) {
@@ -56,11 +54,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 +76,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

@@ -3,6 +3,7 @@ package net.vonforst.evmap.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.SeekBar
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
@@ -13,6 +14,8 @@ 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.ItemFilterSliderBinding
import net.vonforst.evmap.viewmodel.*
interface Equatable {
override fun equals(other: Any?): Boolean;
@@ -29,15 +32,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 +73,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 +113,81 @@ 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
}
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
init {
setHasStableIds(true)
}
val itemids = mutableMapOf<String, Long>()
var maxId = 0L
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 SliderFilter -> R.layout.item_filter_slider
}
override fun bind(
holder: ViewHolder<FilterWithValue<FilterValue>>,
item: 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?) {
}
})
}
}
}
override fun getItemId(position: Int): Long {
val key = getItem(position).filter.key
var value = itemids[key]
if (value == null) {
maxId++
value = maxId
itemids[key] = maxId
}
return value
}
}

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
@@ -44,7 +42,7 @@ class ChargecloudAvailabilityDetector(
if (chargepoint == null) {
// find corresponding chargepoint from goingelectric to get correct power
val geChargepoint =
getCorrespondingChargepoint(location.chargepoints, type, power)
getCorrespondingChargepoint(location.chargepointsMerged, type, power)
?: throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
@@ -72,7 +70,7 @@ class ChargecloudAvailabilityDetector(
if (chargepointStatus.keys == location.chargepoints.toSet()) {
if (chargepointStatus.keys == location.chargepointsMerged.toSet()) {
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"

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,10 +139,13 @@ 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
"Unspecified" -> "unspecified"
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
}
val status = when (statusStr) {
@@ -146,7 +159,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
nmStatus.put(id, status)
}
val match = matchChargepoints(nmConnectors, location.chargepoints)
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { nmStatus[it]!! }
}

View File

@@ -16,7 +16,10 @@ interface GoingElectricApi {
@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>
@GET("chargepoints/")

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,16 +45,33 @@ 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
}
/**
* Merges chargepoints if they have the same plug and power
*
* This occurs e.g. for Type2 sockets and plugs, which are distinct on the GE website, but not
* separable in the API
*/
val chargepointsMerged: List<Chargepoint>
get() {
val variants = chargepoints.distinctBy { it.power to it.type }
return variants.map { variant ->
val count = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
.sumBy { it.count }
Chargepoint(variant.type, variant.power, count)
}
}
fun formatChargepoints(): String {
return chargepoints.map {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
}.joinToString(" · ")
}
@@ -75,7 +99,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 +138,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 +179,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 +226,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,92 @@
package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
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.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
)
)
}
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? ->
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
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
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.launch
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
class FilterFragment : Fragment() {
private lateinit var binding: FragmentFilterBinding
private val vm: FilterViewModel by viewModels(factoryProducer = {
viewModelFactory {
FilterViewModel(
requireActivity().application,
getString(R.string.goingelectric_key)
)
}
})
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
binding.lifecycleOwner = this
binding.vm = vm
setHasOptionsMenu(true)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
exitAfterTransition()
}
})
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.filtersList.apply {
adapter = FiltersAdapter()
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(
context, LinearLayoutManager.VERTICAL
)
)
}
view.startCircularReveal()
toolbar.setNavigationOnClickListener {
exitAfterTransition()
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.filter, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_apply -> {
lifecycleScope.launch {
vm.saveFilterValues()
}
exitAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun exitAfterTransition() {
view?.exitCircularReveal {
findNavController().popBackStack()
}
}
}

View File

@@ -4,23 +4,23 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.core.app.SharedElementCallback
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import androidx.viewpager2.widget.ViewPager2
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.galleryTransitionName
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.databinding.FragmentGalleryBinding
import net.vonforst.evmap.viewmodel.GalleryViewModel
class GalleryFragment : Fragment(), MapsActivity.FragmentCallback {
class GalleryFragment : Fragment() {
companion object {
private const val EXTRA_POSITION = "position"
private const val EXTRA_PHOTOS = "photos"
@@ -42,6 +42,20 @@ class GalleryFragment : Fragment(), MapsActivity.FragmentCallback {
private var currentPage: TouchImageView? = null
private val galleryVm: GalleryViewModel by activityViewModels()
private val backPressedCallback = object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val image = currentPage
if (image != null && image.currentZoom !in 0.95f..1.05f) {
image.setZoomAnimated(1f, 0.5f, 0.5f)
} else {
isReturning = true
galleryVm.galleryPosition.value = currentPosition
findNavController().popBackStack()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -88,6 +102,11 @@ class GalleryFragment : Fragment(), MapsActivity.FragmentCallback {
postponeEnterTransition();
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
backPressedCallback
)
return binding.root
}
@@ -103,39 +122,9 @@ class GalleryFragment : Fragment(), MapsActivity.FragmentCallback {
) {
if (isReturning) {
val currentPage = currentPage ?: return
val index = binding.gallery.currentItem
if (startingPosition != currentPosition) {
names.clear()
names.add(galleryTransitionName(index))
sharedElements.clear()
sharedElements[galleryTransitionName(index)] = currentPage
}
sharedElements[names[0]] = currentPage
}
}
}
override fun getRootView(): View {
return binding.root
}
override fun goBack(): Boolean {
val image = currentPage
if (image != null && image.currentZoom !in 0.95f..1.05f) {
image.setZoomAnimated(1f, 0.5f, 0.5f)
return true
} else {
isReturning = true
galleryVm.galleryPosition.value = currentPosition
return false
}
}
override fun onResume() {
super.onResume()
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
}
}

View File

@@ -3,12 +3,17 @@ 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.activity.OnBackPressedCallback
import androidx.core.app.SharedElementCallback
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -21,6 +26,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
@@ -31,8 +37,10 @@ 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.snackbar.Snackbar
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 kotlinx.android.synthetic.main.fragment_map.*
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
@@ -44,7 +52,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 +66,35 @@ 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
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
@@ -79,10 +108,27 @@ 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
}
setExitSharedElementCallback(exitElementCallback)
exitTransition = TransitionInflater.from(requireContext())
.inflateTransition(R.transition.map_exit_transition)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
backPressedCallback
)
return binding.root
}
@@ -90,6 +136,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 +195,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 = 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)
}
}
@@ -157,22 +240,38 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
backPressedCallback.isEnabled = newState != STATE_HIDDEN
}
})
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
if (it != null) {
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
bottomSheetBehavior.state = STATE_COLLAPSED
}
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
} else {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
bottomSheetBehavior.state = STATE_HIDDEN
}
})
vm.chargepoints.observe(viewLifecycleOwner, Observer {
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 +411,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 +426,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 +446,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
chargerIconGenerator.getBitmapDescriptor(tint)
)
)
animateMarkerAppear(marker, tint, chargerIconGenerator)
animator.animateMarkerAppear(marker, tint)
marker to charger
}.toMap()
@@ -382,12 +480,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
R.id.menu_filter -> {
Snackbar.make(root, R.string.not_implemented, Snackbar.LENGTH_SHORT).show()
return true
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
}
else -> return super.onOptionsItemSelected(item)
else -> super.onOptionsItemSelected(item)
}
}
@@ -404,21 +504,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun goBack(): Boolean {
return if (bottomSheetBehavior.state != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &&
bottomSheetBehavior.state != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
) {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
true
} else if (bottomSheetBehavior.state == BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED) {
vm.chargerSparse.value = null
true
} else {
false
}
}
override fun getRootView(): View {
return root
}
private val exitElementCallback: SharedElementCallback = object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
// Locate the ViewHolder for the clicked position.
val position = galleryVm.galleryPosition.value ?: return
val vh = binding.gallery.findViewHolderForAdapterPosition(position)
if (vh?.itemView == null) return
// Map the first shared element name to the child ImageView.
sharedElements[names[0]] = vh.itemView
}
}
}

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,50 @@
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 androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.viewmodel.BooleanFilterValue
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
import net.vonforst.evmap.viewmodel.SliderFilterValue
@Database(
entities = [
ChargeLocation::class,
BooleanFilterValue::class,
MultipleChoiceFilterValue::class,
SliderFilterValue::class
], version = 2
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
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)
.build()
}
fun getInstance(context: Context): AppDatabase {
this.context = context.applicationContext
return database
}
private val MIGRATION_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// SQL for creating tables copied from build/generated/source/kapt/debug/net/vonforst/evmap/storage/AppDatbase_Impl
db.execSQL("CREATE TABLE IF NOT EXISTS `BooleanFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `MultipleChoiceFilterValue` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, PRIMARY KEY(`key`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `SliderFilterValue` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))");
}
}
}
}

View File

@@ -0,0 +1,55 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import net.vonforst.evmap.viewmodel.BooleanFilterValue
import net.vonforst.evmap.viewmodel.FilterValue
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
import net.vonforst.evmap.viewmodel.SliderFilterValue
@Dao
abstract class FilterValueDao {
@Query("SELECT * FROM booleanfiltervalue")
protected abstract fun getBooleanFilterValues(): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue")
protected abstract fun getMultipleChoiceFilterValues(): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM sliderfiltervalue")
protected abstract fun getSliderFilterValues(): LiveData<List<SliderFilterValue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: MultipleChoiceFilterValue)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: SliderFilterValue)
open fun getFilterValues(): LiveData<List<FilterValue>> =
MediatorLiveData<List<FilterValue>>().apply {
val sources = listOf(
getBooleanFilterValues(),
getMultipleChoiceFilterValues(),
getSliderFilterValues()
)
for (source in sources) {
addSource(source) {
value = sources.mapNotNull { it.value }.flatten()
}
}
}
@Transaction
open suspend fun insert(vararg values: FilterValue) {
values.forEach {
when (it) {
is BooleanFilterValue -> insert(it)
is MultipleChoiceFilterValue -> insert(it)
is SliderFilterValue -> insert(it)
}
}
}
}

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,66 @@
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)
}
private val stringSetAdapter by lazy {
val type = Types.newParameterizedType(Set::class.java, String::class.java)
moshi.adapter<Set<String>>(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)
}
}
@TypeConverter
fun fromStringSet(value: Set<String>?): String {
return stringSetAdapter.toJson(value)
}
@TypeConverter
fun toStringSet(value: String): Set<String>? {
return stringSetAdapter.fromJson(value)
}
}

View File

@@ -0,0 +1,55 @@
package net.vonforst.evmap.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.view.View
import android.view.ViewAnimationUtils
import android.view.animation.DecelerateInterpolator
import kotlin.math.hypot
fun View.startCircularReveal() {
addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int,
oldRight: Int, oldBottom: Int
) {
v.removeOnLayoutChangeListener(this)
val cx = v.right
val cy = v.top
val radius = hypot(right.toDouble(), bottom.toDouble()).toInt()
ViewAnimationUtils.createCircularReveal(v, cx, cy, 0f, radius.toFloat()).apply {
interpolator = DecelerateInterpolator(2f)
duration = 1000
start()
}
}
})
}
fun View.exitCircularReveal(block: () -> Unit) {
val startRadius = hypot(this.width.toDouble(), this.height.toDouble())
ViewAnimationUtils.createCircularReveal(this, this.width, 0, startRadius.toFloat(), 0f).apply {
duration = 350
interpolator = DecelerateInterpolator(1f)
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
visibility = View.INVISIBLE
block()
super.onAnimationEnd(animation)
}
})
start()
}
}
/**
* @return the position of the current [View]'s center in the screen
*/
fun View.findLocationOfCenterOnTheScreen(): IntArray {
val positions = intArrayOf(0, 0)
getLocationInWindow(positions)
// Get the center of the view
positions[0] = positions[0] + width / 2
positions[1] = positions[1] + height / 2
return positions
}

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

@@ -0,0 +1,113 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.databinding.BaseObservable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
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.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
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)
)
}
class FilterViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var db = AppDatabase.getInstance(application)
private val filters = getFilters(application)
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
}
}
}
}
suspend fun saveFilterValues() {
filtersWithValue.value?.forEach {
db.filterValueDao().insert(it.value)
}
}
}
sealed class Filter<out T : FilterValue> : Equatable {
abstract val name: String
abstract val key: String
abstract val valueClass: KClass<out T>
abstract fun defaultValue(): T
}
data class BooleanFilter(override val name: String, override val key: String) :
Filter<BooleanFilterValue>() {
override val valueClass: KClass<BooleanFilterValue> = BooleanFilterValue::class
override fun defaultValue() = BooleanFilterValue(key, false)
}
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>
) : Filter<MultipleChoiceFilterValue>() {
override val valueClass: KClass<MultipleChoiceFilterValue> = MultipleChoiceFilterValue::class
override fun defaultValue() = MultipleChoiceFilterValue(key, emptySet(), true)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, 0)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
}
@Entity
data class BooleanFilterValue(
@PrimaryKey override val key: String,
var value: Boolean
) : FilterValue()
@Entity
data class MultipleChoiceFilterValue(
@PrimaryKey override val key: String,
var values: Set<String>,
var all: Boolean
) : FilterValue()
@Entity
data class SliderFilterValue(
@PrimaryKey override val key: String,
var value: Int
) : FilterValue()
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable

View File

@@ -1,31 +1,26 @@
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
import kotlin.reflect.full.cast
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>()
@@ -34,12 +29,37 @@ class MapViewModel(geApiKey: String) : ViewModel() {
val mapPosition: MutableLiveData<MapPosition> by lazy {
MutableLiveData<MapPosition>()
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues()
}
private val filters = getFilters(application)
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
}
}
}
}
val chargepoints: MediatorLiveData<Resource<List<ChargepointListItem>>> by lazy {
MediatorLiveData<Resource<List<ChargepointListItem>>>()
.apply {
value = Resource.loading(emptyList())
addSource(mapPosition) {
mapPosition.value?.let { pos -> loadChargepoints(pos) }
listOf(mapPosition, filtersWithValue).forEach {
addSource(it) {
val pos = mapPosition.value ?: return@addSource
val filters = filtersWithValue.value ?: return@addSource
loadChargepoints(pos, filters)
}
}
}
}
@@ -87,16 +107,31 @@ class MapViewModel(geApiKey: String) : ViewModel() {
MutableLiveData<Boolean>()
}
private fun loadChargepoints(mapPosition: MapPosition) {
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,
filters: List<FilterWithValue<out FilterValue>>
) {
chargepoints.value = Resource.loading(chargepoints.value?.data)
val bounds = mapPosition.bounds
val zoom = mapPosition.zoom
api.getChargepoints(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude,
clustering = zoom < 13, zoom = zoom,
clusterDistance = 70
).enqueue(object : Callback<ChargepointList> {
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()
@@ -116,27 +151,30 @@ class MapViewModel(geApiKey: String) : ViewModel() {
})
}
private fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: List<FilterWithValue<out FilterValue>>
): Call<ChargepointList> {
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
return 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
)
}
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,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="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>

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
@@ -83,7 +83,7 @@
android:id="@+id/connectors"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:data="@{DataBindingAdaptersKt.chargepointWithAvailability(charger.data.chargepoints, availability.data.status)}"
app:data="@{DataBindingAdaptersKt.chargepointWithAvailability(charger.data.chargepointsMerged, availability.data.status)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView7"

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

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
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.FilterViewModel" />
<variable
name="vm"
type="FilterViewModel" />
</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/filters_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:data="@{vm.filtersWithValue}"
tools:itemCount="3"
tools:listitem="@layout/item_filter_boolean" />
</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,52 @@
<?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.BooleanFilter" />
<import type="net.vonforst.evmap.viewmodel.BooleanFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<variable
name="item"
type="FilterWithValue&lt;BooleanFilterValue&gt;" />
</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:layout_marginBottom="16dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/switch1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Free charging" />
<Switch
android:id="@+id/switch1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:checked="@={item.value.value}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,68 @@
<?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.SliderFilter" />
<import type="net.vonforst.evmap.viewmodel.SliderFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<variable
name="item"
type="FilterWithValue&lt;SliderFilterValue&gt;" />
<variable
name="progress"
type="int" />
</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="Minimum power" />
<SeekBar
android:id="@+id/seekBar"
style="@style/Widget.AppCompat.SeekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:max="@{((SliderFilter) item.filter).max}"
android:progress="@={item.value.value}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView17" />
<TextView
android:id="@+id/textView18"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@{progress + &quot; kW&quot;}"
app:layout_constraintBottom_toBottomOf="@+id/seekBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/seekBar"
tools:text="0 kW" />
</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

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_apply"
android:title="@string/menu_filter"
android:icon="@drawable/ic_check"
app:showAsAction="ifRoom" />
</menu>

View File

@@ -12,18 +12,42 @@
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"
app:popExitAnim="@anim/fragment_fade_exit" />
<action
android:id="@+id/action_map_to_filterFragment"
app:destination="@id/filter"
app:exitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/fragment_fade_enter"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit" />
</fragment>
<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" />
<fragment
android:id="@+id/filter"
android:name="net.vonforst.evmap.fragment.FilterFragment"
android:label="@string/menu_filter"
tools:layout="@layout/fragment_filter" />
</navigation>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="375"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:startDelay="25">
<fade>
<targets>
<target android:targetId="@id/bottom_sheet" />
<target android:targetId="@id/fab_directions" />
</targets>
</fade>
</transitionSet>

View File

@@ -36,4 +36,15 @@
<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>
<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>
</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,15 @@
<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>
<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>
</resources>

View File

@@ -17,4 +17,9 @@
<style name="AppTheme" parent="AppTheme.Base" />
<style name="FullScreenDialogStyle" parent="AppTheme">
<item name="android:windowFullscreen">false</item>
<item name="android:windowIsFloating">false</item>
</style>
</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