Compare commits

...

135 Commits
0.0.4 ... 0.2.2

Author SHA1 Message Date
johan12345
d041513516 Release 0.2.2 2020-07-13 19:42:25 +02:00
johan12345
1effba77d1 update JUnit 2020-07-13 19:37:41 +02:00
Johan von Forstner
df79f02e1d fix crashes with missing internet connection 2020-07-11 18:25:29 +02:00
Johan von Forstner
c4d44f9ddf switch connectors filter to MultiSelectDialog
because Chip interface is buggy
2020-07-05 12:38:58 +02:00
Johan von Forstner
6bec397133 try to improve MultipleChoiceFilter 2020-07-05 11:20:32 +02:00
Johan von Forstner
474b621af0 fix imports 2020-07-05 11:13:23 +02:00
Johan von Forstner
36aeb201ca move some adapters out of DataBindingAdapters.kt 2020-07-05 11:07:36 +02:00
Johan von Forstner
76a241d691 add missing German translations for "map" and "favorites" 2020-07-02 19:55:41 +02:00
Johan von Forstner
0f7bf7913f Release 0.2.1 2020-07-02 19:42:29 +02:00
Johan von Forstner
d11925eb33 update libraries 2020-07-02 19:25:50 +02:00
Johan von Forstner
6ac49fd84d highlight selected charging cards also in detail dialog
(refs #32)
2020-07-02 19:15:44 +02:00
Johan von Forstner
097b7941a2 close keyboard when pressing enter in MultiSelectDialog search 2020-07-02 19:02:45 +02:00
Johan von Forstner
23b87e69c0 highlight selected charging cards in preview of compatible charging cards
(fixes #32)
2020-07-02 18:54:22 +02:00
johan12345
3bb5521c18 minimum connectors filter: start at 1 (fixes #34) 2020-06-30 17:28:16 +02:00
johan12345
76f7b97c1f Set marker color depending on selected connectors (fixes #33) 2020-06-30 16:56:05 +02:00
johan12345
50de0009c7 MultiSelectDialog: sort by name instead of by ID (fixes #31) 2020-06-29 07:54:01 +02:00
johan12345
f906846fcc improve performance of IconGenerator by caching BitmapDescriptors instead of Bitmaps 2020-06-28 20:18:37 +02:00
johan12345
b50225af32 further improvements to MarkerAnimator 2020-06-28 19:39:15 +02:00
Johan von Forstner
8abd5219aa improvements to marker animations 2020-06-27 19:00:13 +02:00
Johan von Forstner
71f9a25c5a IconGenerator: increase cache size 2020-06-27 18:44:22 +02:00
Johan von Forstner
b5f4314795 preserve night mode across app restarts 2020-06-26 08:26:49 +02:00
Johan von Forstner
034196b9fa Add setting to manually enable/disable night mode (fixes #35) 2020-06-25 18:52:30 +02:00
Johan von Forstner
72d7f7dc57 LocaleContextWrapper.kt: remove unused code 2020-06-25 18:52:29 +02:00
johan12345
7fec02b468 Release 0.2.0 2020-06-22 08:31:30 +02:00
johan12345
8eacee8a71 implement dialog with list of all payment methods (fixes #26) 2020-06-21 20:03:50 +02:00
johan12345
95dd8cce52 add database migrations 2020-06-21 19:36:33 +02:00
Johan von Forstner
45dd40faa7 show compatible payment methods in details (#26) 2020-06-21 12:33:53 +02:00
Johan von Forstner
e9ac39301d add splash screen (fixes #27) 2020-06-20 20:35:21 +02:00
Johan von Forstner
8b8713e4c5 save filter enabled/disabled state in SharedPreferences 2020-06-20 13:20:57 +02:00
johan12345
d023facb2f add icon to map marker to show fault reports 2020-06-17 22:46:14 +02:00
johan12345
e2e15692bb add filter to exclude chargers with reported faults 2020-06-17 22:16:10 +02:00
johan12345
abde18d61f allow multiple lines for detail title
(necessary on narrow screens)
2020-06-17 21:44:38 +02:00
johan12345
b32fa6600d support HTML for fault reports 2020-06-17 21:43:18 +02:00
johan12345
1de1699d51 swap colors for >= 11kW and < 11kW
(similar to GE website and Wattfinder)
2020-06-17 21:38:41 +02:00
johan12345
a618c4106f Add filters 24/7 and barrier free 2020-06-17 21:36:07 +02:00
johan12345
6ad8389ecf Power filter: add additional step at 75 kW 2020-06-17 08:54:02 +02:00
johan12345
38d07abf0e Release 0.1.9 2020-06-16 23:15:31 +02:00
johan12345
884172b9f8 add missing dependencies for places library 3.1.0 2020-06-16 22:56:26 +02:00
johan12345
2208e093e7 adapt to billing library changes 2020-06-16 22:44:08 +02:00
johan12345
a2041653bc update dependencies 2020-06-16 22:41:32 +02:00
johan12345
394cbdfc8b update Google Maps SDK to 3.1.0 beta 2020-06-16 22:39:53 +02:00
Johan von Forstner
7759c230db Release 0.1.8 2020-06-15 11:19:11 +02:00
Johan von Forstner
cdc575ff33 add missing libraries causing crash when using the search form 2020-06-15 11:18:43 +02:00
Johan von Forstner
cb250de79e improve openinghours layout 2020-06-14 20:21:13 +02:00
Johan von Forstner
c7885ae729 remove roundet corners at bottom of detail view 2020-06-14 20:07:10 +02:00
Johan von Forstner
024b56952d add unit test for GoingElectric API 2020-06-14 20:01:21 +02:00
Johan von Forstner
75b2240247 Release 0.1.7 2020-06-14 19:21:19 +02:00
Johan von Forstner
d8f011b64b Add error message when internet is not available 2020-06-14 19:19:27 +02:00
Johan von Forstner
a1760a35ff Fix startkey in GE API 2020-06-14 17:48:40 +02:00
Johan von Forstner
e5e5f8ef3c Release 0.1.6 2020-06-14 12:34:36 +02:00
Johan von Forstner
b5a4fe2dc8 Improve filter by number of chargers
- load more pages of GE results
- If server-side clustering is not available, apply local Clustering
2020-06-14 12:33:05 +02:00
Johan von Forstner
676e703a52 upgrade to Google Maps SDK v3 Beta (seems to fix #25) 2020-06-14 11:55:44 +02:00
Johan von Forstner
b9997cbb5a fix exiting with back button 2020-06-13 23:06:45 +02:00
Johan von Forstner
2558052f4f fix charge card filter 2020-06-13 22:58:53 +02:00
Johan von Forstner
980c8cc0af enable Stetho only in debug builds 2020-06-13 22:58:41 +02:00
Johan von Forstner
ffb6740da8 Add language chooser (fixes #24) 2020-06-13 19:52:39 +02:00
Johan von Forstner
2e9112f5c2 Release 0.1.5 2020-06-13 16:44:06 +02:00
Johan von Forstner
3c709fa3c5 add visual and haptic feedback when enabling/disabling filters 2020-06-13 16:19:50 +02:00
Johan von Forstner
11c868af66 remove TODO 2020-06-13 16:08:57 +02:00
Johan von Forstner
e3ea72bac6 implement new selection interface for network and chargecard filters 2020-06-13 16:03:52 +02:00
Johan von Forstner
d01371f6e9 add filters by network and charge card 2020-06-13 15:48:02 +02:00
Johan von Forstner
6130b190e1 disable/enable filters with long click on filter view 2020-06-13 08:04:06 +02:00
johan12345
128d156306 Release 0.1.4 2020-06-01 22:16:30 +02:00
johan12345
f855874d56 fix changed transition API 2020-06-01 22:08:56 +02:00
johan12345
92ebf6c1e5 update some libraries 2020-06-01 21:47:23 +02:00
Johan von Forstner
1e98be0f8f implement full display for opening hours (fixes #23) 2020-06-01 21:34:57 +02:00
Johan von Forstner
c0bec92d4c update Gradle plugin and Kotlin version 2020-06-01 16:35:25 +02:00
Johan von Forstner
71ecd492e9 show error dialog when Google Play Services are not available 2020-05-30 16:25:13 +02:00
Johan von Forstner
fcac8f91ad do not use white nav bar before Android API 27
(otherwise nav buttons are not visible)
2020-05-30 16:07:13 +02:00
johan12345
795c96d901 Release 0.1.3 2020-05-28 09:03:02 +02:00
johan12345
cc76310b2b fix string 2020-05-28 09:02:13 +02:00
johan12345
2a6ac0ac1b Release 0.1.2 2020-05-27 21:08:10 +02:00
johan12345
8673efd1cd favorites view: limit length of text fields 2020-05-27 21:05:37 +02:00
johan12345
ae40b8c634 show fault reports (fixes #2) 2020-05-27 21:03:46 +02:00
johan12345
0cdb12711d do not show opening hours if they are not available 2020-05-27 20:14:49 +02:00
johan12345
69ccc55ad4 move Chargeprice.app button below connectors (#12) 2020-05-27 20:10:37 +02:00
johan12345
304f46e189 fix hiding and showing of layers FAB and menu when detail view is openend 2020-05-26 23:33:01 +02:00
johan12345
01f06621f4 add link to chargeprice.app to compare prices (#12) 2020-05-26 23:09:48 +02:00
Johan von Forstner
f986a68db8 update version code 2020-05-24 16:54:12 +02:00
Johan von Forstner
441e78d807 Release 0.1.1 2020-05-24 16:52:16 +02:00
Johan von Forstner
6481d651a0 add way to quickly enable and disable filters (first step towards #16) 2020-05-24 16:51:18 +02:00
Johan von Forstner
9a7db8997a Add link from coordinates to maps app (fixes #17) 2020-05-24 16:10:33 +02:00
Johan von Forstner
d94053261c remove debugging println call 2020-05-24 15:38:21 +02:00
Johan von Forstner
39dc50724e add FAQ page with legend for marker colors (fixes #21) 2020-05-24 11:54:50 +02:00
Johan von Forstner
34fe126fd0 add option to show Google Maps traffic layer (fixes #19) 2020-05-24 11:26:13 +02:00
Johan von Forstner
1f81a11ad1 add map type chooser 2020-05-24 09:53:56 +02:00
Johan von Forstner
74b74dcd07 add marker for selected search result (fixes #18) 2020-05-24 08:16:04 +02:00
Johan von Forstner
ec623c9396 make clustering more dynamic (fixes #14) 2020-05-23 19:51:44 +02:00
Johan von Forstner
c10c59e3b1 fix lint error 2020-05-22 09:04:23 +02:00
Johan von Forstner
2bd5f746ed Release 0.1.0 2020-05-21 16:46:36 +02:00
Johan von Forstner
fbc15f2925 sort donations by price 2020-05-21 16:45:54 +02:00
Johan von Forstner
11f492df1d Release 0.0.7 2020-05-21 15:11:22 +02:00
Johan von Forstner
629fbb0f1b reduce clusterDistance to 40 2020-05-21 14:58:02 +02:00
Johan von Forstner
d00840c3bd implement donation view 2020-05-21 14:53:30 +02:00
Johan von Forstner
084084c26c fix highlighted charger after moving map 2020-05-19 20:50:54 +02:00
Johan von Forstner
f4b174efe1 bounce marker when selected 2020-05-19 20:46:34 +02:00
Johan von Forstner
81d3ba115a change package name and launcher name for debug version of the app 2020-05-19 20:42:42 +02:00
Johan von Forstner
a35a5f7050 re-add errorneously removed imports 2020-05-19 20:32:45 +02:00
Johan von Forstner
c1cec8781b highlight currently selected chharger (fixes #15) 2020-05-19 20:23:59 +02:00
Johan von Forstner
be98e7e266 Release 0.0.6 2020-05-17 22:40:02 +02:00
Johan von Forstner
49ef661ac1 dark mode: set text color for marker clustering to white 2020-05-17 22:39:12 +02:00
Johan von Forstner
1d98264437 fix color of location button in dark mode 2020-05-17 22:34:29 +02:00
Johan von Forstner
4d137614d5 fix broken database transactions 2020-05-17 22:33:04 +02:00
Johan von Forstner
0bb88c983e Release 0.0.5 2020-05-17 19:25:41 +02:00
Johan von Forstner
d460c34219 disable donations for now 2020-05-17 19:24:24 +02:00
Johan von Forstner
e91b7d26f8 add DonateFragment 2020-05-17 19:23:02 +02:00
Johan von Forstner
12e41bc38f make sure that app does not freeze waiting for picture to load 2020-05-17 14:25:01 +02:00
Johan von Forstner
ea94f67187 add badge showing how many filters are active 2020-05-17 14:17:41 +02:00
Johan von Forstner
9ad2f86b39 plugs filter: return empty list if none chosen (#9) 2020-05-17 13:46:03 +02:00
Johan von Forstner
d71e781c26 multiple choice filter: disable unnecessary buttons (#9) 2020-05-17 13:35:14 +02:00
Johan von Forstner
03410a4c49 show charger status "unknown" as question mark (fixes #7) 2020-05-17 13:21:19 +02:00
Johan von Forstner
3488e89dbc add link from favorites to detail view (fixes #8) 2020-05-16 17:27:29 +02:00
Johan von Forstner
ddbc63ae2a add missing animation file 2020-05-16 17:26:31 +02:00
Johan von Forstner
ee78ca31fe fix race condition when loading chargepoints on app start 2020-05-16 17:06:39 +02:00
Johan von Forstner
f79bd78a5d add empty state for favorites list 2020-05-16 16:34:55 +02:00
Johan von Forstner
374402c43a multiple choice filter: add "show more" button 2020-05-15 19:19:07 +02:00
Johan von Forstner
c82e12bb47 multiple choice filter: add "all" and "none" buttons 2020-05-15 18:56:20 +02:00
Johan von Forstner
02d24a3b3f wait to save filters before closing filter view 2020-05-15 18:52:23 +02:00
Johan von Forstner
4031c8f142 minimum number of chargepoints filter improvements (#9)
- do not use clustering if it needs to be applied
- fix combination with plug type filter
2020-05-15 18:37:17 +02:00
Johan von Forstner
d0851be528 make all address fields nullable 2020-05-15 18:30:56 +02:00
Johan von Forstner
2bd57f85d8 set map padding so that compass is not obstructed by toolbar 2020-05-14 18:47:41 +02:00
Johan von Forstner
eccd29b368 fix crash when NewMotion does not know the EVSEID 2020-05-14 18:41:31 +02:00
Johan von Forstner
4b1a3c424f make plugs filter use the plug list from GE API 2020-05-14 18:35:54 +02:00
Johan von Forstner
391bb094e0 update gradle plugin 2020-05-14 18:35:29 +02:00
Johan von Forstner
d6d5ab05a9 convert filters into LiveData in preparation for loading plug types from API 2020-05-11 20:01:05 +02:00
Johan von Forstner
be29316329 implement filter by connector type (#9) 2020-05-10 20:23:10 +02:00
Johan von Forstner
513d3ce4fb fix details showing links where they shouldn't 2020-05-10 20:19:06 +02:00
Johan von Forstner
7da49b256e minimum power filter: introduce steps 2020-05-10 18:20:09 +02:00
Johan von Forstner
e4932f2e4c update navigation library 2020-05-10 14:07:35 +02:00
Johan von Forstner
5c25ba1eca Update CustomBottomSheetBehavior library
add background behind toolbar buttons
2020-05-09 23:15:52 +02:00
Johan von Forstner
09880a58f4 Update CustomBottomSheetBehavior library 2020-05-09 22:53:48 +02:00
Johan von Forstner
cb6abe53fc add filter by minimum number of connectors (#9) 2020-05-09 12:48:28 +02:00
Johan von Forstner
22744da54b make postcode nullable 2020-05-09 12:47:52 +02:00
Johan von Forstner
8fabfd6aa6 set up OkHttp caching for GoingElectricApi 2020-05-07 20:01:47 +02:00
Johan von Forstner
e44903ff3b Travis CI: only deploy tagged commits 2020-05-07 08:27:44 +02:00
98 changed files with 8208 additions and 498 deletions

View File

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

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 4
versionName "0.0.4"
versionCode 20
versionName "0.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -29,6 +29,10 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
def isRunningOnTravis = System.getenv("CI") == "true"
@@ -72,21 +76,18 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.core:core-ktx:1.3.0'
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.4"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.core:core:1.3.0-rc01'
implementation 'androidx.core:core:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.1.0'
implementation 'com.google.android.material:material:1.2.0-beta01'
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: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'
implementation 'androidx.browser:browser:1.2.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.squareup.retrofit2:retrofit:2.7.2'
implementation 'com.squareup.retrofit2:converter-moshi:2.7.2'
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
@@ -94,9 +95,26 @@ dependencies {
implementation 'com.github.MikeOrtiz:TouchImageView:2.3.3'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks:bimap:1.0.2'
// Google Maps v3 Beta
implementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
implementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
implementation 'com.google.maps.android:android-maps-utils-v3:1.3.3'
implementation 'com.android.volley:volley:1.1.1'
implementation 'com.google.android.gms:play-services-base:17.3.0'
implementation 'com.google.android.gms:play-services-basement:17.3.0'
implementation 'com.google.android.gms:play-services-gcm:17.0.0'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation 'com.google.android.gms:play-services-tasks:17.1.0'
implementation 'com.google.auto.value:auto-value-annotations:1.6.3'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.google.android.datatransport:transport-runtime:2.2.3'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// navigation library
def nav_version = "2.3.0-alpha05"
def nav_version = "2.3.0"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -111,10 +129,15 @@ dependencies {
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "3.0.0"
implementation "com.android.billingclient:billing:$billing_version"
implementation "com.android.billingclient:billing-ktx:$billing_version"
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
testImplementation 'junit:junit:4.12'
testImplementation 'junit:junit:4.13'
testImplementation "com.squareup.okhttp3:mockwebserver:3.14.7"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
@@ -123,5 +146,5 @@ dependencies {
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.9.2"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
}

View File

Binary file not shown.

View File

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

View File

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

View File

@@ -27,7 +27,8 @@
<activity
android:name=".MapsActivity"
android:label="@string/title_activity_maps">
android:label="@string/title_activity_maps"
android:theme="@style/AppTheme.LaunchScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -3,11 +3,14 @@ package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import com.google.android.libraries.places.api.Places
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
class EvMapApplication : Application() {
override fun onCreate() {
super.onCreate()
updateNightMode(PreferenceDataSource(this))
Stetho.initializeWithDefaults(this);
Places.initialize(getApplicationContext(), getString(R.string.google_maps_key));
Places.initialize(applicationContext, getString(R.string.google_maps_key));
}
}

View File

@@ -1,19 +1,27 @@
package net.vonforst.evmap
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
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
import net.vonforst.evmap.utils.LocaleContextWrapper
const val REQUEST_LOCATION_PERMISSION = 1
@@ -28,7 +36,17 @@ class MapsActivity : AppCompatActivity() {
var fragmentCallback: FragmentCallback? = null
private lateinit var prefs: PreferenceDataSource
override fun attachBaseContext(newBase: Context) {
return super.attachBaseContext(
LocaleContextWrapper.wrap(
newBase, PreferenceDataSource(newBase).language
)
);
}
override fun onCreate(savedInstanceState: Bundle?) {
// set theme to AppTheme to end launch screen
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_maps)
@@ -45,36 +63,44 @@ class MapsActivity : AppCompatActivity() {
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
prefs = PreferenceDataSource(this)
checkPlayServices()
}
fun navigateTo(charger: ChargeLocation) {
val intent = Intent(Intent.ACTION_VIEW)
val coord = charger.coordinates
// google maps navigation
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
val pm = packageManager
if (intent.resolveActivity(pm) != null && prefs.navigateUseMaps) {
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
startActivity(intent);
} else {
// fallback: generic geo intent
intent.data = Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
if (intent.resolveActivity(pm) != null) {
startActivity(intent);
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
)
}
showLocation(charger)
}
}
fun showLocation(charger: ChargeLocation) {
val coord = charger.coordinates
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent);
} else {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
R.string.no_maps_app_found,
Snackbar.LENGTH_SHORT
)
}
}
fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
val intent = CustomTabsIntent.Builder()
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
.build()
intent.launchUrl(this, Uri.parse(url))
}
fun shareUrl(url: String) {
@@ -84,4 +110,19 @@ class MapsActivity : AppCompatActivity() {
}
startActivity(intent)
}
private fun checkPlayServices(): Boolean {
val request = 9000
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(this)
if (resultCode != ConnectionResult.SUCCESS) {
if (apiAvailability.isUserResolvableError(resultCode)) {
apiAvailability.getErrorDialog(this, resultCode, request).show()
} else {
Log.d("EVMap", "This device is not supported.")
}
return false
}
return true
}
}

View File

@@ -0,0 +1,54 @@
package net.vonforst.evmap
import android.graphics.Typeface
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
val dbl = this.getDouble(name, Double.NaN)
return if (dbl.isNaN()) null else dbl
}
fun Bundle.optLong(name: String): Long? {
if (!this.containsKey(name)) return null
val lng = this.getLong(name, Long.MIN_VALUE)
return if (lng == Long.MIN_VALUE) null else lng
}
fun <T> Iterable<T>.joinToSpannedString(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
truncated: CharSequence = "...",
transform: ((T) -> CharSequence)? = null
): CharSequence {
return SpannedString(
joinTo(
SpannableStringBuilder(),
separator,
prefix,
postfix,
limit,
truncated,
transform
)
)
}
operator fun CharSequence.plus(other: CharSequence): CharSequence {
return TextUtils.concat(this, other)
}
fun String.bold(): CharSequence {
return SpannableString(this).apply {
setSpan(
StyleSpan(Typeface.BOLD), 0, this.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}

View File

@@ -1,9 +1,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
@@ -12,17 +10,18 @@ import androidx.recyclerview.widget.RecyclerView
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.viewmodel.DonationItem
import net.vonforst.evmap.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean;
}
abstract class DataBindingAdapter<T : Equatable>() :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback()) {
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
var onClickListener: ((T) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> {
val layoutInflater = LayoutInflater.from(parent.context)
@@ -41,10 +40,20 @@ abstract class DataBindingAdapter<T : Equatable>() :
open fun bind(holder: ViewHolder<T>, item: T) {
holder.binding.setVariable(BR.item, item)
holder.binding.executePendingBindings()
if (onClickListener != null) {
holder.binding.root.setOnClickListener {
val listener = onClickListener ?: return@setOnClickListener
listener(item)
}
}
}
class DiffCallback<T : Equatable> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = oldItem === newItem
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = if (getKey != null) {
(getKey)(oldItem) == (getKey)(newItem)
} else {
oldItem === newItem
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = oldItem == newItem
}
@@ -55,75 +64,21 @@ fun chargepointWithAvailability(
availability: Map<Chargepoint, List<ChargepointStatus>>?
): List<ConnectorAdapter.ChargepointWithAvailability>? {
return chargepoints?.map {
ConnectorAdapter.ChargepointWithAvailability(
it, availability?.get(it)?.count { it == ChargepointStatus.AVAILABLE }
)
val status = availability?.get(it)
ConnectorAdapter.ChargepointWithAvailability(it, status)
}
}
class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvailability>() {
data class ChargepointWithAvailability(val chargepoint: Chargepoint, val available: Int?) :
data class ChargepointWithAvailability(
val chargepoint: Chargepoint,
val status: List<ChargepointStatus>?
) :
Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class DetailAdapter : DataBindingAdapter<DetailAdapter.Detail>() {
data class Detail(
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_detail
}
fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail> {
if (loc == null) return emptyList()
return listOfNotNull(
DetailAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription
),
if (loc.operator != null) DetailAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,
loc.operator
) else null,
if (loc.network != null) DetailAdapter.Detail(
R.drawable.ic_network,
R.string.network,
loc.network
) else null,
// TODO: separate layout for opening hours with expandable details
if (loc.openinghours != null) DetailAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
loc.openinghours.getStatusText(ctx),
loc.openinghours.description
) else null,
if (loc.cost != null) DetailAdapter.Detail(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
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>() {
@@ -136,58 +91,6 @@ class FavoritesAdapter(val vm: FavoritesViewModel) :
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
}
class DonationAdapter() : DataBindingAdapter<DonationItem>() {
override fun getItemViewType(position: Int): Int = R.layout.item_donation
}

View File

@@ -0,0 +1,139 @@
package net.vonforst.evmap.adapter
import android.content.Context
import androidx.core.text.HtmlCompat
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeCardId
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.OpeningHoursDays
import net.vonforst.evmap.bold
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.plus
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
class DetailsAdapter : DataBindingAdapter<DetailsAdapter.Detail>() {
data class Detail(
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true,
val clickable: Boolean = false,
val hoursDays: OpeningHoursDays? = null
) : Equatable
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
if (item.hoursDays != null) {
return R.layout.item_detail_openinghours
} else {
return R.layout.item_detail
}
}
}
fun buildDetails(
loc: ChargeLocation?,
chargeCards: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): List<DetailsAdapter.Detail> {
if (loc == null) return emptyList()
return listOfNotNull(
DetailsAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription
),
if (loc.operator != null) DetailsAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,
loc.operator
) else null,
if (loc.network != null) DetailsAdapter.Detail(
R.drawable.ic_network,
R.string.network,
loc.network
) else null,
if (loc.faultReport != null) DetailsAdapter.Detail(
R.drawable.ic_fault_report,
R.string.fault_report,
loc.faultReport.created?.let {
ctx.getString(
R.string.fault_report_date,
loc.faultReport.created
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
} ?: "",
loc.faultReport.description?.let {
HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY)
} ?: "",
clickable = true
) else null,
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailsAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
loc.openinghours.getStatusText(ctx),
loc.openinghours.description,
hoursDays = loc.openinghours.days
) else null,
if (loc.cost != null) DetailsAdapter.Detail(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) DetailsAdapter.Detail(
R.drawable.ic_payment,
R.string.charge_cards,
ctx.resources.getQuantityString(
R.plurals.charge_cards_compatible_num,
loc.chargecards.size, loc.chargecards.size
),
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx),
clickable = true
) else null,
DetailsAdapter.Detail(
R.drawable.ic_location,
R.string.coordinates,
loc.coordinates.formatDMS(),
loc.coordinates.formatDecimal(),
links = false,
clickable = true
)
)
}
fun formatChargeCards(
chargecards: List<ChargeCardId>,
chargecardData: Map<Long, ChargeCard>?,
filteredChargeCards: Set<Long>?,
ctx: Context
): CharSequence {
if (chargecardData == null) return ""
val maxItems = 5
var result = chargecards
.sortedByDescending { filteredChargeCards?.contains(it.id) }
.take(maxItems)
.mapNotNull {
val name = chargecardData[it.id]?.name ?: return@mapNotNull null
if (filteredChargeCards?.contains(it.id) == true) {
name.bold()
} else {
name
}
}.joinToSpannedString()
if (chargecards.size > maxItems) {
result += " " + ctx.getString(R.string.and_n_others, chargecards.size - maxItems)
}
return result
}

View File

@@ -0,0 +1,227 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.databinding.Observable
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceBinding
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceLargeBinding
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.fragment.MultiSelectDialog
import net.vonforst.evmap.viewmodel.*
import kotlin.math.max
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
init {
setHasStableIds(true)
}
val itemids = mutableMapOf<String, Long>()
var maxId = 0L
override fun getItemViewType(position: Int): Int =
when (val filter = getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> {
if (filter.manyChoices) {
R.layout.item_filter_multiple_choice_large
} else {
R.layout.item_filter_multiple_choice
}
}
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 -> {
setupSlider(
holder.binding as ItemFilterSliderBinding,
item.filter as SliderFilter, item.value
)
}
is MultipleChoiceFilterValue -> {
val filter = item.filter as MultipleChoiceFilter
if (filter.manyChoices) {
setupMultipleChoiceMany(
holder.binding as ItemFilterMultipleChoiceLargeBinding,
filter, item.value
)
} else {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
filter, item.value
)
}
}
}
}
private fun setupMultipleChoice(
binding: ItemFilterMultipleChoiceBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
// TODO: this implementation seems to be buggy
val inflater = LayoutInflater.from(binding.root.context)
value.values.toList().forEach {
// delete values that cannot be selected anymore
if (it !in filter.choices.keys) value.values.remove(it)
}
fun updateButtons() {
value.all = value.values == filter.choices.keys
binding.btnAll.isEnabled = !value.all
binding.btnNone.isEnabled = value.values.isNotEmpty()
}
val chips = mutableMapOf<String, Chip>()
// reuse existing chips in layout
val reuseChips = binding.chipGroup.children.filter {
it.id != R.id.chipMore
}.toMutableList()
binding.chipGroup.children.forEach {
if (it.id != R.id.chipMore) binding.chipGroup.removeView(it)
}
filter.choices.entries.sortedByDescending {
it.key in value.values
}.sortedByDescending {
if (filter.commonChoices != null) it.key in filter.commonChoices else false
}.forEach { choice ->
var reused = false
val chip = if (reuseChips.size > 0) {
reused = true
reuseChips.removeAt(0) as Chip
} else {
inflater.inflate(
R.layout.item_filter_multiple_choice_chip,
binding.chipGroup,
false
) as Chip
}
chip.text = choice.value
chip.isChecked = choice.key in value.values || value.all
if (value.all && choice.key !in value.values) value.values.add(choice.key)
chip.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
value.values.add(choice.key)
} else {
value.values.remove(choice.key)
}
updateButtons()
}
if (filter.commonChoices != null && choice.key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
if (!reused) binding.chipGroup.addView(chip, binding.chipGroup.childCount - 1)
chips[choice.key] = chip
}
// delete surplus reusable chips
reuseChips.forEach {
binding.chipGroup.removeView(it)
}
binding.btnAll.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = true }
updateButtons()
}
binding.btnNone.setOnClickListener {
value.all = true
value.values.addAll(filter.choices.keys)
chips.values.forEach { it.isChecked = false }
updateButtons()
}
binding.chipMore.setOnClickListener {
binding.showingAll = !binding.showingAll
chips.forEach { (key, chip) ->
if (filter.commonChoices != null && key !in filter.commonChoices
&& !(chip.isChecked && !value.all) && !binding.showingAll
) {
chip.visibility = View.GONE
} else {
chip.visibility = View.VISIBLE
}
}
}
updateButtons()
}
private fun setupMultipleChoiceMany(
binding: ItemFilterMultipleChoiceLargeBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
if (value.all) {
value.values = filter.choices.keys.toMutableSet()
binding.notifyPropertyChanged(BR.item)
}
binding.btnEdit.setOnClickListener {
val dialog =
MultiSelectDialog.getInstance(
filter.name,
filter.choices,
value.values,
commonChoices = filter.commonChoices
)
dialog.okListener = { selected ->
value.values = selected.toMutableSet()
value.all = value.values == filter.choices.keys
binding.item = binding.item
}
dialog.show((binding.root.context as AppCompatActivity).supportFragmentManager, null)
}
}
private fun setupSlider(
binding: ItemFilterSliderBinding,
filter: SliderFilter,
value: SliderFilterValue
) {
binding.progress =
max(filter.inverseMapping(value.value) - filter.min, 0)
binding.mappedValue = filter.mapping(binding.progress + filter.min)
binding.addOnPropertyChangedCallback(object :
Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
when (propertyId) {
BR.progress -> {
val mapped = filter.mapping(binding.progress + filter.min)
value.value = mapped
binding.mappedValue = mapped
}
}
}
})
}
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

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

View File

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

View File

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

View File

@@ -12,8 +12,11 @@ import kotlinx.android.parcel.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
@@ -21,7 +24,20 @@ import kotlin.math.floor
@JsonClass(generateAdapter = true)
data class ChargepointList(
val status: String,
val chargelocations: List<ChargepointListItem>
val chargelocations: List<ChargepointListItem>,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
data class StringList(
val status: String,
val result: List<String>
)
@JsonClass(generateAdapter = true)
data class ChargeCardList(
val status: String,
val result: List<ChargeCard>
)
sealed class ChargepointListItem
@@ -36,7 +52,7 @@ data class ChargeLocation(
val chargepoints: List<Chargepoint>,
@JsonObjectOrFalse val network: String?,
val url: String,
// @Json(name = "fault_report") val faultReport: Boolean, <- Object or false in detail, true or false in overview
@Embedded(prefix = "fault_report_") @JsonObjectOrFalse @Json(name = "fault_report") val faultReport: FaultReport?,
val verified: Boolean,
// only shown in details:
@JsonObjectOrFalse val operator: String?,
@@ -44,15 +60,26 @@ data class ChargeLocation(
@JsonObjectOrFalse @Json(name = "ladeweile") val amenities: String?,
@JsonObjectOrFalse @Json(name = "location_description") val locationDescription: String?,
val photos: List<ChargerPhoto>?,
//val chargecards: Boolean?
@JsonObjectOrFalse val chargecards: List<ChargeCardId>?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?
) : ChargepointListItem(), Equatable {
/**
* maximum power available from this charger.
*/
val maxPower: Double
get() {
return chargepoints.map { it.power }.max() ?: 0.0
return maxPower()
}
/**
* Gets the maximum power available from certain connectors of this charger.
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.max() ?: 0.0
}
/**
* Merges chargepoints if they have the same plug and power
*
@@ -101,6 +128,10 @@ data class OpeningHours(
@JsonObjectOrFalse val description: String?,
@Embedded val days: OpeningHoursDays?
) {
val isEmpty: Boolean
get() = description == "Leider noch keine Informationen zu Öffnungszeiten vorhanden."
&& days == null && !twentyfourSeven
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
@@ -128,8 +159,6 @@ data class OpeningHours(
), 0
)
}
} else if (description != null) {
return description
} else {
return ""
}
@@ -149,9 +178,12 @@ data class OpeningHoursDays(
) {
fun getHoursForDate(date: LocalDate): Hours {
// TODO: check for holidays
return getHoursForDayOfWeek(date.dayOfWeek)
}
fun getHoursForDayOfWeek(dayOfWeek: DayOfWeek?): Hours {
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
return when (date.dayOfWeek) {
return when (dayOfWeek) {
DayOfWeek.MONDAY -> monday
DayOfWeek.TUESDAY -> tuesday
DayOfWeek.WEDNESDAY -> wednesday
@@ -159,6 +191,7 @@ data class OpeningHoursDays(
DayOfWeek.FRIDAY -> friday
DayOfWeek.SATURDAY -> saturday
DayOfWeek.SUNDAY -> sunday
null -> holiday
}
}
}
@@ -166,7 +199,16 @@ data class OpeningHoursDays(
data class Hours(
val start: LocalTime?,
val end: LocalTime?
)
) {
override fun toString(): String {
if (start != null && end != null) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return "${start.format(fmt)} - ${end.format(fmt)}"
} else {
return "closed"
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
@@ -204,13 +246,13 @@ data class Coordinate(val lat: Double, val lng: Double) {
@JsonClass(generateAdapter = true)
data class Address(
val city: String,
val country: String,
val postcode: String,
val street: String
@JsonObjectOrFalse val city: String?,
@JsonObjectOrFalse val country: String?,
@JsonObjectOrFalse val postcode: String?,
@JsonObjectOrFalse val street: String?
) {
override fun toString(): String {
return "$street, $postcode $city"
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
}
@@ -236,4 +278,20 @@ data class Chargepoint(val type: String, val power: Double, val count: Int) : Eq
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
}
}
}
@JsonClass(generateAdapter = true)
data class FaultReport(val created: Instant?, val description: String?)
@Entity
@JsonClass(generateAdapter = true)
data class ChargeCard(
@Json(name = "card_id") @PrimaryKey val id: Long,
val name: String,
val url: String
)
@JsonClass(generateAdapter = true)
data class ChargeCardId(
val id: Long
)

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ 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 com.google.android.libraries.maps.model.LatLng
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.FavoritesAdapter
@@ -66,8 +66,13 @@ class FavoritesFragment : Fragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
val favAdapter = FavoritesAdapter(vm).apply {
onClickListener = {
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
}
}
binding.favsList.apply {
adapter = FavoritesAdapter(vm)
adapter = favAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(

View File

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

View File

@@ -7,10 +7,15 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.SharedElementCallback
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
@@ -27,26 +32,30 @@ import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.*
import com.google.android.libraries.maps.CameraUpdateFactory
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.OnMapReadyCallback
import com.google.android.libraries.maps.SupportMapFragment
import com.google.android.libraries.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.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.android.synthetic.main.fragment_map.*
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.REQUEST_LOCATION_PERMISSION
import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
@@ -56,12 +65,12 @@ import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.GalleryViewModel
import net.vonforst.evmap.viewmodel.MapPosition
import net.vonforst.evmap.viewmodel.MapViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import net.vonforst.evmap.viewmodel.*
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback {
private lateinit var binding: FragmentMapBinding
@@ -78,8 +87,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private var markers: Map<Marker, ChargeLocation> = emptyMap()
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -87,11 +99,19 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private lateinit var favToggle: MenuItem
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val value = vm.layersMenuOpen.value
if (value != null && value) {
closeLayersMenu()
return
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
} else if (state == STATE_HIDDEN) {
vm.searchResult.value = null
}
}
}
@@ -178,17 +198,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
binding.fabLayers.setOnClickListener {
openLayersMenu()
}
binding.detailView.goingelectricButton.setOnClickListener {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
}
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
(activity as? MapsActivity)?.openUrl(
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
val fields = listOf(Place.Field.LAT_LNG)
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
val intent: Intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, fields
)
@@ -221,6 +249,32 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
private fun openLayersMenu() {
binding.fabLayers.tag = false
val materialTransform = MaterialContainerTransform().apply {
startView = binding.fabLayers
endView = binding.layersSheet
setPathMotion(MaterialArcMotion())
duration = 250
scrimColor = Color.TRANSPARENT
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
vm.layersMenuOpen.value = true
}
private fun closeLayersMenu() {
binding.fabLayers.tag = true
val materialTransform = MaterialContainerTransform().apply {
startView = binding.layersSheet
endView = binding.fabLayers
setPathMotion(MaterialArcMotion())
duration = 200
scrimColor = Color.TRANSPARENT
}
TransitionManager.beginDelayedTransition(binding.root, materialTransform)
vm.layersMenuOpen.value = false
}
private fun toggleFavorite() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
@@ -240,7 +294,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onStateChanged(bottomSheet: View, newState: Int) {
vm.bottomSheetState.value = newState
backPressedCallback.isEnabled = newState != STATE_HIDDEN
updateBackPressedCallback()
if (vm.layersMenuOpen.value!! && newState !in listOf(BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING, BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN, BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED)) {
closeLayersMenu()
}
}
})
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
@@ -251,17 +309,112 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.fabDirections.show()
detailAppBarBehavior.setToolbarTitle(it.name)
updateFavoriteToggle()
highlightMarker(it)
} else {
bottomSheetBehavior.state = STATE_HIDDEN
unhighlightAllMarkers()
}
})
vm.chargepoints.observe(viewLifecycleOwner, Observer {
val chargepoints = it.data
if (chargepoints != null) updateMap(chargepoints)
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.retry) {
connectionErrorSnackbar?.dismiss()
vm.reloadChargepoints()
}
connectionErrorSnackbar!!.show()
}
Status.SUCCESS -> {
connectionErrorSnackbar?.dismiss()
}
Status.LOADING -> {
}
}
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
})
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
val map = this.map ?: return@Observer
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
if (place.viewport != null) {
map.animateCamera(CameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(CameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
searchResultMarker = map.addMarker(MarkerOptions().position(place.latLng!!))
}
updateBackPressedCallback()
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
binding.fabLayers.visibility = if (open) View.GONE else View.VISIBLE
binding.layersSheet.visibility = if (open) View.VISIBLE else View.GONE
updateBackPressedCallback()
})
vm.mapType.observe(viewLifecycleOwner, Observer {
map?.mapType = it
})
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
map?.isTrafficEnabled = it
})
updateBackPressedCallback()
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
}
private fun unhighlightAllMarkers() {
markers.forEach { (m, c) ->
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value), fault = c.faultReport != null
)
)
}
}
private fun highlightMarker(charger: ChargeLocation) {
val marker = markers.inverse[charger] ?: return
// highlight this marker
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = true,
fault = charger.faultReport != null
)
)
animator.animateMarkerBounce(marker)
// un-highlight all other markers
markers.forEach { (m, c) ->
if (m != marker) {
m.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(c, vm.filteredConnectors.value), fault = c.faultReport != null
)
)
}
}
}
private fun updateFavoriteToggle() {
@@ -307,6 +460,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
startPostponedEnterTransition()
} else {
binding.gallery.scrollToPosition(galleryPosition)
// make sure that the app does not freeze waiting for a picture to load
Handler().postDelayed({
startPostponedEnterTransition()
}, 500)
}
binding.detailView.connectors.apply {
@@ -317,7 +474,24 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.details.apply {
adapter = DetailAdapter()
adapter = DetailsAdapter().apply {
onClickListener = {
val charger = vm.chargerDetails.value?.data
if (charger != null) {
when (it.icon) {
R.drawable.ic_location -> {
(activity as? MapsActivity)?.showLocation(charger)
}
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
}
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
}
}
}
}
}
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
@@ -330,9 +504,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
private fun showPaymentMethodsDialog(charger: ChargeLocation) {
val activity = activity ?: return
val chargecardData = vm.chargeCardMap.value ?: return
val chargecards = charger.chargecards ?: return
val filteredChargeCards = vm.filteredChargeCards.value
val data = chargecards.mapNotNull { chargecardData[it.id] }
.sortedBy { it.name }
.sortedByDescending { filteredChargeCards?.contains(it.id) }
val names = data.map {
if (filteredChargeCards?.contains(it.id) == true) {
it.name.bold()
} else {
it.name
}
}
AlertDialog.Builder(activity)
.setTitle(R.string.charge_cards)
.setItems(names.toTypedArray()) { _, i ->
val card = data[i]
(activity as? MapsActivity)?.openUrl("https:${card.url}")
}.show()
}
override fun onMapReady(map: GoogleMap) {
this.map = map
map.uiSettings.isTiltGesturesEnabled = false;
map.uiSettings.isTiltGesturesEnabled = false
map.isIndoorEnabled = false
map.uiSettings.isIndoorLevelPickerEnabled = false
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
@@ -354,9 +554,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
map.setOnMapClickListener {
vm.chargerSparse.value = null
if (backPressedCallback.isEnabled) {
backPressedCallback.handleOnBackPressed()
}
}
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
if (mode == Configuration.UI_MODE_NIGHT_YES) {
@@ -366,23 +571,51 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val position = vm.mapPosition.value
if (hasLocationPermission()) {
enableLocation(position == null, false)
} else if (position == null) {
// center the camera on Europe
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.moveCamera(cameraUpdate)
}
val lat = arguments?.optDouble(ARG_LAT)
val lon = arguments?.optDouble(ARG_LON)
var positionSet = false
if (position != null) {
val cameraUpdate =
CameraUpdateFactory.newLatLngZoom(position.bounds.center, position.zoom)
map.moveCamera(cameraUpdate)
} else {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
map.moveCamera(cameraUpdate)
// show charger detail after chargers were loaded
val chargerId = arguments?.optLong(ARG_CHARGER_ID)
vm.chargepoints.observe(
viewLifecycleOwner,
object : Observer<Resource<List<ChargepointListItem>>> {
override fun onChanged(res: Resource<List<ChargepointListItem>>) {
if (res.data == null) return
for (item in res.data) {
if (item is ChargeLocation && item.id == chargerId) {
vm.chargerSparse.value = item
vm.chargepoints.removeObserver(this)
}
}
}
})
positionSet = true
}
if (hasLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
if (!positionSet) {
// center the camera on Europe
val cameraUpdate = CameraUpdateFactory.newLatLngZoom(LatLng(50.113388, 9.252536), 3.5f)
map.moveCamera(cameraUpdate)
}
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
}
@SuppressLint("MissingPermission")
@@ -414,6 +647,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
) == PackageManager.PERMISSION_GRANTED
}
@Synchronized
private fun updateMap(chargepoints: List<ChargepointListItem>) {
val map = this.map ?: return
clusterMarkers.forEach { it.remove() }
@@ -422,34 +656,55 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val chargers = chargepoints.filterIsInstance<ChargeLocation>()
val chargepointIds = chargers.map { it.id }.toSet()
markers = markers.filter {
if (!chargepointIds.contains(it.value.id)) {
val tint = getMarkerTint(it.value)
if (it.key.isVisible) {
animator.animateMarkerDisappear(it.key, tint)
} else {
it.key.remove()
}
false
} else {
true
}
}
markers = markers + chargers.filter {
!markers.containsValue(it)
}.map { charger ->
val tint = getMarkerTint(charger)
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.icon(
chargerIconGenerator.getBitmapDescriptor(tint)
)
)
animator.animateMarkerAppear(marker, tint)
marker to charger
}.toMap()
// update icons of existing markers (connector filter may have changed)
for ((marker, charger) in markers) {
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
fault = charger.faultReport != null
)
)
}
if (chargers.toSet() != markers.values) {
// remove markers that disappeared
val bounds = map.projection.visibleRegion.latLngBounds
markers.entries.toList().forEach {
val marker = it.key
val charger = it.value
if (!chargepointIds.contains(charger.id)) {
// animate marker if it is visible, otherwise remove immediately
if (bounds.contains(marker.position)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
animator.animateMarkerDisappear(marker, tint, highlight, fault)
} else {
animator.deleteMarker(marker)
}
markers.remove(marker)
}
}
// add new markers
val map1 = markers.values.map { it.id }
for (charger in chargers) {
if (!map1.contains(charger.id)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val fault = charger.faultReport != null
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.visible(false)
)
animator.animateMarkerAppear(marker, tint, highlight, fault)
markers[marker] = charger
}
}
previousChargepointIds = chargepointIds
}
clusterMarkers = clusters.map { cluster ->
map.addMarker(
MarkerOptions()
@@ -476,18 +731,61 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.map, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_filter -> {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
val filterItem = menu.findItem(R.id.menu_filter)
val filterView = filterItem.actionView
val filterBadge = filterView?.findViewById<TextView>(R.id.filter_badge)
if (filterBadge != null) {
// set up badge showing number of active filters
vm.filtersCount.observe(viewLifecycleOwner, Observer {
filterBadge.visibility = if (it > 0) View.VISIBLE else View.GONE
filterBadge.text = it.toString()
})
}
filterView?.setOnClickListener {
val popup = PopupMenu(requireContext(), it, Gravity.END)
popup.menuInflater.inflate(R.menu.popup_filter, popup.menu)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_edit_filters -> {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
)
true
}
R.id.menu_filters_active -> {
vm.filtersActive.value = !vm.filtersActive.value!!
true
}
else -> false
}
}
else -> super.onOptionsItemSelected(item)
val checkItem = popup.menu.findItem(R.id.menu_filters_active)
vm.filtersActive.observe(viewLifecycleOwner, Observer {
checkItem.isChecked = it
})
popup.show()
}
filterView?.setOnLongClickListener {
// enable/disable filters
vm.filtersActive.value = !vm.filtersActive.value!!
// haptic feedback
filterView.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
// show snackbar
Snackbar.make(
requireView(), if (vm.filtersActive.value!!) {
R.string.filters_activated
} else {
R.string.filters_deactivated
}, Snackbar.LENGTH_SHORT
).show()
true
}
}
@@ -495,9 +793,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK) {
val place = Autocomplete.getPlaceFromIntent(data!!)
val zoom = 12f
map?.animateCamera(CameraUpdateFactory.newLatLngZoom(place.latLng, zoom))
vm.searchResult.value = Autocomplete.getPlaceFromIntent(data!!)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
@@ -523,4 +819,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
sharedElements[names[0]] = vh.itemView
}
}
companion object {
fun showCharger(charger: ChargeLocation): Bundle {
return Bundle().apply {
putLong(ARG_CHARGER_ID, charger.id)
putDouble(ARG_LAT, charger.coordinates.lat)
putDouble(ARG_LON, charger.coordinates.lng)
}
}
}
}

View File

@@ -0,0 +1,121 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.dialog_multi_select.*
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.Equatable
import java.util.*
import kotlin.collections.HashMap
import kotlin.collections.HashSet
class MultiSelectDialog : AppCompatDialogFragment() {
companion object {
fun getInstance(
title: String,
data: Map<String, String>,
selected: Set<String>,
commonChoices: Set<String>?
): MultiSelectDialog {
val dialog = MultiSelectDialog()
dialog.arguments = Bundle().apply {
putString("title", title)
putSerializable("data", HashMap(data))
putSerializable("selected", HashSet(selected))
if (commonChoices != null) putSerializable("commonChoices", HashSet(commonChoices))
}
return dialog
}
}
var okListener: ((Set<String>) -> Unit)? = null
var cancelListener: (() -> Unit)? = null
private lateinit var items: List<MultiSelectItem>
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.dialog_multi_select, container)
}
override fun onStart() {
super.onStart()
// dialog with 95% screen height
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val data = args.getSerializable("data") as HashMap<String, String>
val selected = args.getSerializable("selected") as HashSet<String>
val title = args.getString("title")
val commonChoices = if (args.containsKey("commonChoices")) {
args.getSerializable("commonChoices") as HashSet<String>
} else null
dialogTitle.text = title
val adapter = Adapter()
list.adapter = adapter
list.layoutManager = LinearLayoutManager(view.context)
items = data.entries.toList()
.sortedBy { it.value }
.sortedByDescending { commonChoices?.contains(it.key) == true }
.map { MultiSelectItem(it.key, it.value, it.key in selected) }
adapter.submitList(items)
etSearch.doAfterTextChanged { text ->
adapter.submitList(search(items, text.toString()))
}
btnCancel.setOnClickListener {
cancelListener?.let { listener ->
listener()
}
dismiss()
}
btnOK.setOnClickListener {
okListener?.let { listener ->
val result = items.filter { it.selected }.map { it.key }.toSet()
listener(result)
}
dismiss()
}
btnAll.setOnClickListener {
items = items.map { MultiSelectItem(it.key, it.name, true) }
adapter.submitList(search(items, etSearch.text.toString()))
}
btnNone.setOnClickListener {
items = items.map { MultiSelectItem(it.key, it.name, false) }
adapter.submitList(search(items, etSearch.text.toString()))
}
}
}
private fun search(
items: List<MultiSelectItem>,
text: String
): List<MultiSelectItem> {
return items.filter { item ->
// search for string within name
text.toLowerCase(Locale.getDefault()) in item.name.toLowerCase(Locale.getDefault())
}
}
class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
}
data class MultiSelectItem(val key: String, val name: String, var selected: Boolean) : Equatable

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.fragment
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
@@ -9,12 +10,18 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
class SettingsFragment : PreferenceFragmentCompat() {
class SettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var prefs: PreferenceDataSource
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
prefs = PreferenceDataSource(requireContext())
val navController = findNavController()
toolbar.setupWithNavController(
@@ -33,4 +40,29 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"language" -> {
activity?.let {
it.finish();
it.startActivity(it.intent);
}
}
"darkmode" -> {
updateNightMode(prefs)
}
}
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
}

View File

@@ -0,0 +1,54 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.io.IOException
import java.time.Duration
import java.time.Instant
@Dao
interface ChargeCardDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg chargeCards: ChargeCard)
@Delete
suspend fun delete(vararg chargeCards: ChargeCard)
@Query("SELECT * FROM chargeCard")
fun getAllChargeCards(): LiveData<List<ChargeCard>>
}
class ChargeCardRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: ChargeCardDao, private val prefs: PreferenceDataSource
) {
fun getChargeCards(): LiveData<List<ChargeCard>> {
scope.launch {
updateChargeCards()
}
return dao.getAllChargeCards()
}
private suspend fun updateChargeCards() {
if (Duration.between(prefs.lastChargeCardUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getChargeCards()
if (!response.isSuccessful) return
for (card in response.body()!!.result) {
dao.insert(card)
}
prefs.lastChargeCardUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

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

View File

@@ -0,0 +1,56 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.io.IOException
import java.time.Duration
import java.time.Instant
@Entity
data class Network(@PrimaryKey val name: String)
@Dao
interface NetworkDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg networks: Network)
@Delete
suspend fun delete(vararg networks: Network)
@Query("SELECT * FROM network")
fun getAllNetworks(): LiveData<List<Network>>
}
class NetworkRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: NetworkDao, private val prefs: PreferenceDataSource
) {
fun getNetworks(): LiveData<List<Network>> {
scope.launch {
updateNetworks()
}
return dao.getAllNetworks()
}
private suspend fun updateNetworks() {
if (Duration.between(prefs.lastNetworkUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getNetworks()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Network(name))
}
prefs.lastNetworkUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

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

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.storage
import android.content.Context
import androidx.preference.PreferenceManager
import java.time.Instant
class PreferenceDataSource(context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
@@ -11,4 +12,34 @@ class PreferenceDataSource(context: Context) {
set(value) {
sp.edit().putBoolean("navigate_use_maps", value).apply()
}
var lastPlugUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_plug_update", 0L))
set(value) {
sp.edit().putLong("last_plug_update", value.toEpochMilli()).apply()
}
var lastNetworkUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_network_update", 0L))
set(value) {
sp.edit().putLong("last_network_update", value.toEpochMilli()).apply()
}
var lastChargeCardUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_chargecard_update", 0L))
set(value) {
sp.edit().putLong("last_chargecard_update", value.toEpochMilli()).apply()
}
var filtersActive: Boolean
get() = sp.getBoolean("filters_active", true)
set(value) {
sp.edit().putBoolean("filters_active", value).apply()
}
val language: String
get() = sp.getString("language", "default")!!
val darkmode: String
get() = sp.getString("darkmode", "default")!!
}

View File

@@ -3,8 +3,10 @@ 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.ChargeCardId
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import java.time.Instant
import java.time.LocalTime
class Converters {
@@ -17,6 +19,10 @@ class Converters {
val type = Types.newParameterizedType(List::class.java, ChargerPhoto::class.java)
moshi.adapter<List<ChargerPhoto>>(type)
}
private val chargeCardIdListAdapter by lazy {
val type = Types.newParameterizedType(List::class.java, ChargeCardId::class.java)
moshi.adapter<List<ChargeCardId>>(type)
}
private val stringSetAdapter by lazy {
val type = Types.newParameterizedType(Set::class.java, String::class.java)
moshi.adapter<Set<String>>(type)
@@ -42,6 +48,16 @@ class Converters {
return chargerPhotoListAdapter.fromJson(value)
}
@TypeConverter
fun fromChargeCardIdList(value: List<ChargeCardId>?): String {
return chargeCardIdListAdapter.toJson(value)
}
@TypeConverter
fun toChargeCardIdList(value: String?): List<ChargeCardId>? {
return value?.let { chargeCardIdListAdapter.fromJson(it) }
}
@TypeConverter
fun fromLocalTime(value: LocalTime?): String? {
return value?.toString()
@@ -49,11 +65,23 @@ class Converters {
@TypeConverter
fun toLocalTime(value: String?): LocalTime? {
return value.let {
return value?.let {
LocalTime.parse(it)
}
}
@TypeConverter
fun fromInstant(value: Instant?): Long? {
return value?.toEpochMilli()
}
@TypeConverter
fun toInstant(value: Long?): Instant? {
return value?.let {
Instant.ofEpochMilli(it)
}
}
@TypeConverter
fun fromStringSet(value: Set<String>?): String {
return stringSetAdapter.toJson(value)

View File

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

View File

@@ -0,0 +1,41 @@
package net.vonforst.evmap.ui;
import com.google.android.libraries.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
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.api.goingelectric.Coordinate
fun cluster(
result: List<ChargepointListItem>,
zoom: Float,
clusterDistance: Int
): List<ChargepointListItem> {
val clusters = result.filterIsInstance<ChargeLocationCluster>()
val locations = result.filterIsInstance<ChargeLocation>()
val clusterItems = locations.map { ChargepointClusterItem(it) }
val algo = NonHierarchicalDistanceBasedAlgorithm<ChargepointClusterItem>()
algo.maxDistanceBetweenClusteredItems = clusterDistance
algo.addItems(clusterItems)
return algo.getClusters(zoom).map {
if (it.size == 1) {
it.items.first().charger
} else {
ChargeLocationCluster(it.size, Coordinate(it.position.latitude, it.position.longitude))
}
} + clusters
}
private class ChargepointClusterItem(val charger: ChargeLocation) : ClusterItem {
override fun getSnippet(): String? = null
override fun getTitle(): String? = charger.name
override fun getPosition(): LatLng = LatLng(charger.coordinates.lat, charger.coordinates.lng)
}

View File

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

View File

@@ -10,8 +10,9 @@ import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import androidx.core.widget.TextViewCompat
import com.google.android.libraries.maps.model.BitmapDescriptor
import com.google.android.libraries.maps.model.BitmapDescriptorFactory
import com.google.maps.android.ui.IconGenerator
import com.google.maps.android.ui.SquareTextView
import net.vonforst.evmap.R
@@ -20,7 +21,6 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
init {
setBackground(context.getDrawable(R.drawable.marker_cluster_bg))
setContentView(makeSquareTextView(context))
setTextAppearance(R.style.TextAppearance_AppCompat_Inverse)
}
private fun makeSquareTextView(context: Context): SquareTextView? {
@@ -34,28 +34,34 @@ class ClusterIconGenerator(context: Context) : IconGenerator(context) {
)
id = com.google.maps.android.R.id.amu_text
setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
}
}
class ChargerIconGenerator(val context: Context) {
data class BitmapData(val tint: Int, val scale: Int, val alpha: Int)
data class BitmapData(
val tint: Int,
val scale: Int,
val alpha: Int,
val highlight: Boolean,
val fault: Boolean
)
val cacheSize = 4 * 1024 * 1024; // 4MiB
val cache = object : LruCache<BitmapData, Bitmap>(cacheSize) {
override fun sizeOf(key: BitmapData, value: Bitmap): Int {
return value.byteCount
}
}
val oversize = 1f // increase to add padding for overshoot scale animation
val cacheSize = 420; // 420 items: 21 sizes, 5 colors, highlight on/off, fault on/off
val cache = LruCache<BitmapData, BitmapDescriptor>(cacheSize)
val oversize = 1.4f // increase to add padding for fault icon or scale > 1
val icon = R.drawable.ic_map_marker_charging
val highlightIcon = R.drawable.ic_map_marker_highlight
val faultIcon = R.drawable.ic_map_marker_fault
init {
preloadCache()
}
fun preloadCache() {
private fun preloadCache() {
// pre-generates images for scale from 0 to 255 for all possible tint colors
val tints = listOf(
R.color.charger_100kw,
@@ -64,10 +70,13 @@ class ChargerIconGenerator(val context: Context) {
R.color.charger_11kw,
R.color.charger_low
)
for (tint in tints) {
for (scale in 0..20) {
val data = BitmapData(tint, scale, 255)
cache.put(data, generateBitmap(data))
for (fault in listOf(false, true)) {
for (highlight in listOf(false, true)) {
for (tint in tints) {
for (scale in 0..20) {
getBitmapDescriptor(tint, scale, 255, highlight, fault)
}
}
}
}
}
@@ -75,16 +84,19 @@ class ChargerIconGenerator(val context: Context) {
fun getBitmapDescriptor(
@ColorRes tint: Int,
scale: Int = 20,
alpha: Int = 255
alpha: Int = 255,
highlight: Boolean = false,
fault: Boolean = false
): BitmapDescriptor? {
val data = BitmapData(tint, scale, alpha)
val data = BitmapData(tint, scale, alpha, highlight, fault)
val cachedImg = cache[data]
return if (cachedImg != null) {
BitmapDescriptorFactory.fromBitmap(cachedImg)
cachedImg
} else {
val bitmap = generateBitmap(data)
cache.put(data, bitmap)
BitmapDescriptorFactory.fromBitmap(bitmap)
val bmd = BitmapDescriptorFactory.fromBitmap(bitmap)
cache.put(data, bmd)
bmd
}
}
@@ -118,6 +130,33 @@ class ChargerIconGenerator(val context: Context) {
)
vd.draw(canvas)
if (data.highlight) {
val highlightDrawable = context.getDrawable(highlightIcon)!!
highlightDrawable.setBounds(
leftPadding.toInt(), topPadding.toInt(),
leftPadding.toInt() + vd.intrinsicWidth,
topPadding.toInt() + vd.intrinsicHeight
)
highlightDrawable.alpha = data.alpha
highlightDrawable.draw(canvas)
}
if (data.fault) {
val faultDrawable = context.getDrawable(faultIcon)!!
val faultSize = 0.75
val faultShift = 0.25
val base = vd.intrinsicWidth
faultDrawable.setBounds(
(leftPadding.toInt() + base * (1 - faultSize + faultShift)).toInt(),
(topPadding.toInt() - base * faultShift).toInt(),
(leftPadding.toInt() + base * (1 + faultShift)).toInt(),
(topPadding.toInt() + base * (faultSize - faultShift)).toInt()
)
faultDrawable.alpha = data.alpha
faultDrawable.draw(canvas)
}
return bm
}
}

View File

@@ -1,80 +1,130 @@
package net.vonforst.evmap.ui
import android.animation.ValueAnimator
import android.view.animation.BounceInterpolator
import androidx.core.animation.addListener
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.gms.maps.model.Marker
import com.google.android.libraries.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import kotlin.math.max
fun getMarkerTint(charger: ChargeLocation): Int = when {
charger.maxPower >= 100 -> R.color.charger_100kw
charger.maxPower >= 43 -> R.color.charger_43kw
charger.maxPower >= 20 -> R.color.charger_20kw
charger.maxPower >= 11 -> R.color.charger_11kw
fun getMarkerTint(
charger: ChargeLocation,
connectors: Set<String>?
): Int = when {
charger.maxPower(connectors) >= 100 -> R.color.charger_100kw
charger.maxPower(connectors) >= 43 -> R.color.charger_43kw
charger.maxPower(connectors) >= 20 -> R.color.charger_20kw
charger.maxPower(connectors) >= 11 -> R.color.charger_11kw
else -> R.color.charger_low
}
class MarkerAnimator(val gen: ChargerIconGenerator) {
val animatingMarkers = hashMapOf<Marker, ValueAnimator>()
private val animatingMarkers = hashMapOf<String, ValueAnimator>()
fun animateMarkerAppear(
marker: Marker,
tint: Int
tint: Int,
highlight: Boolean,
fault: Boolean
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
animatingMarkers[marker.id]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
}
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)
gen.getBitmapDescriptor(
tint,
scale = scale,
highlight = highlight,
fault = fault
)
)
marker.isVisible = true
}
addListener(onEnd = {
animatingMarkers.remove(marker)
animatingMarkers.remove(marker.id)
}, onCancel = {
animatingMarkers.remove(marker.id)
})
}
animatingMarkers[marker] = anim
animatingMarkers[marker.id] = anim
anim.start()
}
fun animateMarkerDisappear(
marker: Marker,
tint: Int
tint: Int,
highlight: Boolean,
fault: Boolean
) {
animatingMarkers[marker]?.cancel()
animatingMarkers.remove(marker)
animatingMarkers[marker.id]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
}
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)
gen.getBitmapDescriptor(
tint,
scale = scale,
highlight = highlight,
fault = fault
)
)
}
addListener(onEnd = {
animatingMarkers.remove(marker)
marker.remove()
animatingMarkers.remove(marker.id)
}, onCancel = {
marker.remove()
animatingMarkers.remove(marker.id)
})
}
animatingMarkers[marker] = anim
animatingMarkers[marker.id] = anim
anim.start()
}
fun deleteMarker(marker: Marker) {
animatingMarkers[marker.id]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
}
marker.remove()
}
fun animateMarkerBounce(marker: Marker) {
animatingMarkers[marker.id]?.let {
it.cancel()
animatingMarkers.remove(marker.id)
}
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 700
interpolator = BounceInterpolator()
addUpdateListener { state ->
val t = max(1f - state.animatedValue as Float, 0f) / 2
marker.setAnchor(0.5f, 1.0f + t)
}
addListener(onEnd = {
animatingMarkers.remove(marker.id)
}, onCancel = {
animatingMarkers.remove(marker.id)
})
}
animatingMarkers[marker.id] = anim
anim.start()
}
}

View File

@@ -0,0 +1,14 @@
package net.vonforst.evmap.ui
import androidx.appcompat.app.AppCompatDelegate
import net.vonforst.evmap.storage.PreferenceDataSource
fun updateNightMode(prefs: PreferenceDataSource) {
AppCompatDelegate.setDefaultNightMode(
when (prefs.darkmode) {
"on" -> AppCompatDelegate.MODE_NIGHT_YES
"off" -> AppCompatDelegate.MODE_NIGHT_NO
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
}

View File

@@ -0,0 +1,36 @@
package net.vonforst.evmap.utils
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Build
import java.util.*
class LocaleContextWrapper(base: Context?) : ContextWrapper(base) {
companion object {
fun wrap(context: Context, language: String): ContextWrapper {
val config: Configuration = context.resources.configuration
var sysLocale: Locale? = null
sysLocale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.locales.get(0)
} else {
@Suppress("DEPRECATION")
config.locale
}
var ctx = context
if (language != "" && language != "default" && sysLocale.language != language) {
val locale = Locale(language)
Locale.setDefault(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.setLocale(locale)
} else {
@Suppress("DEPRECATION")
config.locale = locale
}
ctx = context.createConfigurationContext(config)
}
return LocaleContextWrapper(ctx)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,25 +2,39 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.model.LatLngBounds
import com.google.android.libraries.places.api.model.Place
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
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 net.vonforst.evmap.api.goingelectric.*
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlin.reflect.full.cast
import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
internal fun getClusterDistance(zoom: Float): Int? {
return when (zoom) {
in 0.0..7.0 -> 100
in 7.0..11.5 -> 75
in 11.5..12.5 -> 60
in 12.5..13.0 -> 45
else -> null
}
}
class MapViewModel(application: Application, geApiKey: String) : AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey)
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var chargepointLoader: Job? = null
val bottomSheetState: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
@@ -32,20 +46,38 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues()
}
private val filters = getFilters(application)
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
}
private val chargeCards: LiveData<List<ChargeCard>> by lazy {
ChargeCardRepository(api, viewModelScope, db.chargeCardDao(), prefs).getChargeCards()
}
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
MediatorLiveData<List<FilterWithValue<out FilterValue>>>().apply {
addSource(filterValues) {
val values = filterValues.value
if (values != null) {
value = filters.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
} else {
value = null
filtersWithValue(filters, filterValues, filtersActive)
}
val chargeCardMap: LiveData<Map<Long, ChargeCard>> by lazy {
MediatorLiveData<Map<Long, ChargeCard>>().apply {
value = null
addSource(chargeCards) {
value = chargeCards.value?.map {
it.id to it
}?.toMap()
}
}
}
val filtersCount: LiveData<Int> by lazy {
MediatorLiveData<Int>().apply {
value = 0
addSource(filtersWithValue) { filtersWithValue ->
value = filtersWithValue.count {
it.filter.defaultValue() != it.value
}
}
}
@@ -56,13 +88,17 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
value = Resource.loading(emptyList())
listOf(mapPosition, filtersWithValue).forEach {
addSource(it) {
val pos = mapPosition.value ?: return@addSource
val filters = filtersWithValue.value ?: return@addSource
loadChargepoints(pos, filters)
reloadChargepoints()
}
}
}
}
val filteredConnectors: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>()
}
val filteredChargeCards: MutableLiveData<Set<Long>> by lazy {
MutableLiveData<Set<Long>>()
}
val chargerSparse: MutableLiveData<ChargeLocation> by lazy {
MutableLiveData<ChargeLocation>()
@@ -106,11 +142,45 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
val layersMenuOpen: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val favorites: LiveData<List<ChargeLocation>> by lazy {
db.chargeLocationsDao().getAllChargeLocations()
}
val searchResult: MutableLiveData<Place> by lazy {
MutableLiveData<Place>()
}
val mapType: MutableLiveData<Int> by lazy {
MutableLiveData<Int>().apply {
value = GoogleMap.MAP_TYPE_NORMAL
}
}
val mapTrafficEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val filtersActive: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = prefs.filtersActive
observeForever {
prefs.filtersActive = it
}
}
}
fun setMapType(type: Int) {
mapType.value = type
}
fun insertFavorite(charger: ChargeLocation) {
viewModelScope.launch {
db.chargeLocationsDao().insert(charger)
@@ -123,55 +193,162 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
fun reloadChargepoints() {
val pos = mapPosition.value ?: return
val filters = filtersWithValue.value ?: return
loadChargepoints(pos, filters)
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: List<FilterWithValue<out FilterValue>>
) {
chargepointLoader?.cancel()
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredChargeCards.value = null
val bounds = mapPosition.bounds
val zoom = mapPosition.zoom
getChargepointsWithFilters(bounds, zoom, filters).enqueue(object :
Callback<ChargepointList> {
override fun onFailure(call: Call<ChargepointList>, t: Throwable) {
chargepoints.value = Resource.error(t.message, chargepoints.value?.data)
t.printStackTrace()
}
override fun onResponse(
call: Call<ChargepointList>,
response: Response<ChargepointList>
) {
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargepoints.value =
Resource.error(response.message(), chargepoints.value?.data)
} else {
chargepoints.value = Resource.success(response.body()!!.chargelocations)
}
}
})
chargepointLoader = viewModelScope.launch {
val result = getChargepointsWithFilters(bounds, zoom, filters)
filteredConnectors.value = result.second
filteredChargeCards.value = result.third
chargepoints.value = result.first
}
}
private fun getChargepointsWithFilters(
private suspend 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
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
val freecharging = getBooleanValue(filters, "freecharging")
val freeparking = getBooleanValue(filters, "freeparking")
val open247 = getBooleanValue(filters, "open_247")
val barrierfree = getBooleanValue(filters, "barrierfree")
val excludeFaults = getBooleanValue(filters, "exclude_faults")
val minPower = getSliderValue(filters, "min_power")
val minConnectors = getSliderValue(filters, "min_connectors")
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
)
val connectorsVal = getMultipleChoiceValue(filters, "connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Triple(Resource.success(emptyList()), null, null)
}
val connectors = formatMultipleChoice(connectorsVal)
val filteredConnectors = if (connectorsVal.all) null else connectorsVal.values
val chargeCardsVal = getMultipleChoiceValue(filters, "chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Triple(Resource.success(emptyList()), filteredConnectors, null)
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val filteredChargeCards =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val networksVal = getMultipleChoiceValue(filters, "networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val networks = formatMultipleChoice(networksVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
var startkey: Int? = null
val data = mutableListOf<ChargepointListItem>()
do {
// load all pages of the response
try {
val response = api.getChargepoints(
bounds.southwest.latitude,
bounds.southwest.longitude,
bounds.northeast.latitude,
bounds.northeast.longitude,
clustering = useGeClustering,
zoom = zoom,
clusterDistance = clusterDistance,
freecharging = freecharging,
minPower = minPower,
freeparking = freeparking,
open247 = open247,
barrierfree = barrierfree,
excludeFaults = excludeFaults,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Triple(
Resource.error(response.message(), chargepoints.value?.data),
null,
null
)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Triple(
Resource.error(e.message, chargepoints.value?.data),
filteredConnectors,
filteredChargeCards
)
}
} while (startkey != null && startkey < 10000)
var result = data.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is ChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
} else {
true
}
}
if (!geClusteringAvailable && useClustering) {
// apply local clustering if server side clustering is not available
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return Triple(Resource.success(result), filteredConnectors, filteredChargeCards)
}
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private fun getBooleanValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as BooleanFilterValue).value
private fun getSliderValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as SliderFilterValue).value
private fun getMultipleChoiceFilter(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.filter as MultipleChoiceFilter
private fun getMultipleChoiceValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)

View File

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

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?selectableItemBackgroundBorderless"/>
<item android:drawable="@drawable/expand_toggle_icon"
android:top="4dp"
android:left="4dp"
android:right="4dp"
android:bottom="4dp"/>
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" android:drawable="@drawable/ic_expand" />
<item android:state_checked="true" android:drawable="@drawable/ic_collapse" />
</selector>

View File

@@ -0,0 +1,49 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="144.3dp"
android:height="270.5dp"
android:viewportWidth="144.3"
android:viewportHeight="270.5">
<path
android:pathData="M33.9,100l-2.5,-21.7l-3.8,0.4l2.5,21.7L33.9,100zM47.4,98.5l-2.5,-21.7l-3.8,0.4l2.5,21.7L47.4,98.5z"
android:fillColor="#FFB300" />
<path
android:pathData="M54.5,128c-1.2,1.4 -2.1,2.4 -2.2,2.5c-3.4,2.7 -6.1,3.5 -8.4,2.5c-3.9,-2 -3.7,-9.3 -3.5,-10.1l2.7,0.1c-0.1,2.1 0.3,6.5 2.1,7.5c1,0.5 2.9,-0.1 5.2,-2.1l0,0c0,0 7.6,-7.6 6,-13.6c-1.8,-7.2 6.5,-17.5 9.3,-21.1l0.4,-0.4l2.2,1.7l-0.4,0.5c-8.5,10.5 -9.4,15.8 -8.8,18.6C60.5,119.4 57,125 54.5,128z"
android:fillColor="#90A4AE" />
<path
android:pathData="M25.6,99.8l1,8.9l8.2,5.5L46,113l6.8,-7.2l-1,-8.9L25.6,99.8z"
android:fillColor="#90A4AE" />
<path
android:pathData="M45.8,113l-11.1,1.2l2.4,9.8l8.8,-1V113L45.8,113zM53.8,89.4l0.9,8.1l-31.9,3.7l-0.9,-8.1L53.8,89.4z"
android:fillColor="#546E7A" />
<path
android:pathData="M78.8,0C55.9,0 37.3,18.6 37.3,41.5c0,31.3 34.9,47.6 39.1,92.2c0.1,1.3 1.2,2.2 2.5,2.2s2.4,-0.9 2.5,-2.2c4.2,-44.6 39.1,-60.9 39.1,-92.2C120.3,18.4 101.7,0 78.8,0z"
android:fillColor="#00E676" />
<path
android:pathData="M78.8,0.9c22.8,0 41.2,18.3 41.5,40.9c0,-0.1 0,-0.3 0,-0.4C120.3,18.6 101.7,0 78.8,0S37.3,18.4 37.3,41.5c0,0.1 0,0.3 0,0.4C37.6,19.2 56,0.9 78.8,0.9L78.8,0.9z"
android:fillColor="#FFFFFF"
android:fillAlpha="0.2" />
<path
android:pathData="M81.3,132.6c-0.1,1.3 -1.2,2.2 -2.5,2.2c-1.3,0 -2.4,-0.9 -2.5,-2.2c-4.1,-44.5 -38.7,-60.8 -39,-91.7c0,0.3 0,0.4 0,0.7c0,31.3 34.9,47.6 39.1,92.2c0.1,1.3 1.2,2.2 2.5,2.2c1.3,0 2.4,-0.9 2.5,-2.2c4.2,-44.6 39.1,-60.9 39.1,-92.2c0,-0.3 0,-0.4 0,-0.7C120,71.8 85.3,88.1 81.3,132.6L81.3,132.6z"
android:fillColor="#3E2723"
android:fillAlpha="0.2" />
<path
android:fillColor="#FF000000"
android:pathData="M69.3,21.2v25.1h6.8v20.5l16,-27.5h-9.2L92,21.1C92.1,21.2 69.3,21.2 69.3,21.2z"
android:strokeAlpha="0.45"
android:fillAlpha="0.45" />
<path
android:fillColor="?android:textColorSecondary"
android:pathData="M19.2,244.2H2.8v14.1h18.8v2.4H0v-34.1h21.5v2.4H2.8v12.8h16.4V244.2z" />
<path
android:fillColor="?android:textColorSecondary"
android:pathData="M37.2,254.9l0.7,2.3h0.1l0.7,-2.3L49,226.6h3l-12.7,34.1h-2.6l-12.7,-34.1h3L37.2,254.9z" />
<path
android:fillColor="?android:textColorSecondary"
android:pathData="M60.9,226.6l12.5,30h0.1l12.6,-30h3.7v34.1h-2.8v-15.1l0.2,-14.9l-0.1,0l-12.7,30h-1.9l-12.7,-29.9l-0.1,0l0.3,14.8v15.1h-2.8v-34.1H60.9z" />
<path
android:fillColor="?android:textColorSecondary"
android:pathData="M114.1,260.7c-0.2,-0.9 -0.3,-1.6 -0.4,-2.2s-0.1,-1.3 -0.1,-1.9c-0.9,1.3 -2.2,2.4 -3.8,3.3s-3.3,1.3 -5.3,1.3c-2.5,0 -4.4,-0.7 -5.8,-2s-2.1,-3.1 -2.1,-5.3c0,-2.3 1,-4.2 3,-5.6s4.8,-2.1 8.2,-2.1h5.6v-3.1c0,-1.8 -0.6,-3.2 -1.7,-4.3s-2.8,-1.5 -4.9,-1.5c-2,0 -3.6,0.5 -4.9,1.5s-1.9,2.2 -1.9,3.6l-2.6,0l0,-0.1c-0.1,-1.9 0.8,-3.6 2.6,-5.1s4.1,-2.2 6.9,-2.2c2.8,0 5,0.7 6.8,2.1s2.6,3.5 2.6,6.1v12.5c0,0.9 0.1,1.8 0.2,2.6s0.3,1.7 0.5,2.5H114.1zM104.9,258.7c2,0 3.8,-0.5 5.3,-1.4s2.7,-2.2 3.4,-3.6v-5.3H108c-2.5,0 -4.6,0.5 -6.1,1.6s-2.3,2.4 -2.3,4c0,1.4 0.5,2.5 1.4,3.4S103.3,258.7 104.9,258.7z" />
<path
android:fillColor="?android:textColorSecondary"
android:pathData="M144.3,248.7c0,3.8 -0.9,6.8 -2.6,9.1s-4.1,3.4 -7.1,3.4c-1.8,0 -3.3,-0.3 -4.7,-1s-2.4,-1.6 -3.3,-2.9v13.1h-2.8v-35.1h2.4l0.4,3.9c0.8,-1.4 1.9,-2.5 3.3,-3.3s2.9,-1.1 4.7,-1.1c3,0 5.4,1.2 7.1,3.6s2.6,5.7 2.6,9.7V248.7zM141.5,248.2c0,-3.2 -0.6,-5.8 -1.9,-7.9c-1.3,-2 -3.2,-3 -5.6,-3c-1.9,0 -3.4,0.4 -4.6,1.3c-1.2,0.9 -2.1,2.1 -2.7,3.5v12.2c0.6,1.4 1.6,2.5 2.8,3.3s2.7,1.2 4.5,1.2c2.5,0 4.3,-0.9 5.6,-2.8c1.3,-1.8 1.9,-4.3 1.9,-7.3V248.2z" />
</vector>

View File

@@ -0,0 +1,4 @@
<vector android:height="15.811624dp" android:viewportHeight="131.5"
android:viewportWidth="199.6" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M197.544,65.685l-9.2,-4.8l8,-4.2c2.7,-1.4 2.7,-3.8 0,-5.2l-8.6,-4.5l8.6,-4.5c2.7,-1.4 2.7,-3.8 0,-5.2l-68.9,-36.1c-2.7,-1.4 -7.2,-1.4 -9.9,0l-115.5,59.7c-2.7,1.4 -2.7,3.7 0,5.1l8.8,4.5l-8.8,4.6c-2.7,1.4 -2.7,3.7 0,5.1l9.4,4.8l-8.2,4.3c-2.7,1.4 -2.7,3.7 0,5.1l70.4,36.2c2.7,1.4 7.2,1.4 9.9,0l114,-59.6C200.344,69.385 200.344,67.085 197.544,65.685L197.544,65.685zM123.144,18.785L105.844,38.685c-0.9,1 -0.6,2.3 0.6,2.9l13.7,7.1c1.2,0.6 1.2,1.6 0,2.2l-43.1,22.3c-1.2,0.6 -1.4,0.3 -0.6,-0.7l17.3,-19.9c0.9,-1 0.6,-2.3 -0.6,-2.9l-13.7,-7.1c-1.2,-0.6 -1.2,-1.6 0,-2.2l43.1,-22.3C123.744,17.485 123.944,17.785 123.144,18.785L123.144,18.785z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z" />
</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="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</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.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,18.54l-7.37,-5.73L3,14.07l9,7 9,-7 -1.63,-1.27zM12,16l7.36,-5.73L21,9l-9,-7 -9,7 1.63,1.27L12,16zM12,4.53L17.74,9 12,13.47 6.26,9 12,4.53z" />
</vector>

View File

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

View File

@@ -0,0 +1,12 @@
<vector android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/black"
android:pathData="M 1 21 h 22 L 12 2 L 1 21 z" />
<path
android:fillColor="#FF9100"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
</vector>

View File

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

View File

@@ -0,0 +1,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="M20,4L4,4c-1.11,0 -1.99,0.89 -1.99,2L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,6c0,-1.11 -0.89,-2 -2,-2zM20,18L4,18v-6h16v6zM20,8L4,8L4,6h16v2z" />
</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="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<item
android:drawable="@drawable/ic_appicon_splashscreen"
android:gravity="center" />
</layer-list>

View File

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

View File

@@ -9,10 +9,14 @@
<import type="net.vonforst.evmap.api.goingelectric.Chargepoint" />
<import type="net.vonforst.evmap.api.goingelectric.ChargeCard" />
<import type="net.vonforst.evmap.api.availability.ChargeLocationStatus" />
<import type="net.vonforst.evmap.adapter.DataBindingAdaptersKt" />
<import type="net.vonforst.evmap.adapter.DetailsAdapterKt" />
<import type="net.vonforst.evmap.viewmodel.Resource" />
<import type="net.vonforst.evmap.viewmodel.Status" />
@@ -25,12 +29,22 @@
name="availability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="chargeCards"
type="java.util.Map&lt;Long, ChargeCard&gt;" />
<variable
name="filteredChargeCards"
type="java.util.Set&lt;Long&gt;" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardCornerRadius="@dimen/detail_corner_radius"
android:layout_marginBottom="@dimen/detail_corner_radius_negative"
android:paddingBottom="@dimen/detail_corner_radius"
app:cardElevation="6dp">
<androidx.constraintlayout.widget.ConstraintLayout
@@ -180,11 +194,11 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false"
app:data="@{DataBindingAdaptersKt.buildDetails(charger.data, context)}"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView13"
app:layout_constraintTop_toBottomOf="@+id/btnChargeprice"
tools:itemCount="3"
tools:listitem="@layout/item_detail" />
@@ -230,6 +244,18 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/textView" />
<Button
android:id="@+id/btnChargeprice"
style="@style/Widget.MaterialComponents.Button.OutlinedButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/go_to_chargeprice"
app:icon="@drawable/ic_chargeprice"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/dialogTitle"
style="@style/MaterialAlertDialog.MaterialComponents.Title.Text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toStartOf="@+id/btnAll"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Select Something" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toTopOf="@+id/btnOK"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilSearch" />
<Button
android:id="@+id/btnAll"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all"
app:layout_constraintBaseline_toBaselineOf="@+id/dialogTitle"
app:layout_constraintEnd_toStartOf="@+id/btnNone" />
<Button
android:id="@+id/btnOK"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/ok"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnCancel"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnOK" />
<Button
android:id="@+id/btnNone"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/none"
app:layout_constraintBaseline_toBaselineOf="@+id/btnAll"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSearch"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:hint="@string/search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnAll"
app:startIconDrawable="@drawable/ic_search">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.fragment.MultiSelectItem" />
<variable
name="item"
type="MultiSelectItem" />
</data>
<CheckBox
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:layout_marginStart="?attr/dialogPreferredPadding"
android:layout_marginEnd="?attr/dialogPreferredPadding"
android:paddingStart="20dp"
android:ellipsize="marquee"
android:text="@{item.name}"
android:checked="@={item.selected}"
tools:text="Item"
tools:ignore="RtlSymmetry" />
</layout>

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,14 @@
<variable
name="item"
type="net.vonforst.evmap.adapter.DetailAdapter.Detail" />
type="net.vonforst.evmap.adapter.DetailsAdapter.Detail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:clickable="@{item.clickable}"
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
@@ -23,7 +25,6 @@
android:layout_marginTop="18dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:maxLines="1"
android:text="@{item.text}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintBottom_toBottomOf="parent"
@@ -56,6 +57,7 @@
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -0,0 +1,203 @@
<?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="android.text.util.Linkify" />
<import type="java.time.DayOfWeek" />
<import type="android.transition.TransitionManager" />
<variable
name="item"
type="net.vonforst.evmap.adapter.DetailsAdapter.Detail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="@{item.clickable}"
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="14dp"
android:text="@{item.text}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toEndOf="@+id/imageView3"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
<ImageView
android:id="@+id/imageView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@{item.contentDescription}"
android:tint="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@{item.icon}"
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
<include
android:id="@+id/hours_mon"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:dayOfWeek="@{DayOfWeek.MONDAY}"
app:goneUnless="@{expandToggle.checked}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView8" />
<include
android:id="@+id/hours_tue"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.TUESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_mon" />
<include
android:id="@+id/hours_wed"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.WEDNESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_tue" />
<include
android:id="@+id/hours_thu"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.THURSDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_wed" />
<include
android:id="@+id/hours_fri"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.FRIDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_thu" />
<include
android:id="@+id/hours_sat"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.SATURDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_fri" />
<include
android:id="@+id/hours_sun"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.SUNDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_sat" />
<include
android:id="@+id/hours_holiday"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{null}"
app:hours="@{item.hoursDays}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_sun" />
<ToggleButton
android:id="@+id/expandToggle"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="@{item.detailText != null ? @dimen/expand_toggle_padding_large : @dimen/expand_toggle_padding_small}"
android:layout_marginEnd="16dp"
android:background="@drawable/expand_toggle"
android:onClick="@{() -> TransitionManager.beginDelayedTransition(container)}"
android:textOff=""
android:textOn=""
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="java.time.format.TextStyle" />
<import type="java.util.Locale" />
<variable
name="hours"
type="net.vonforst.evmap.api.goingelectric.OpeningHoursDays" />
<variable
name="dayOfWeek"
type="java.time.DayOfWeek" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView24"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{dayOfWeek != null ? dayOfWeek.getDisplayName(TextStyle.FULL, context.resources.configuration.locale) : @string/holiday}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Montag" />
<TextView
android:id="@+id/textView25"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@{hours.getHoursForDayOfWeek(dayOfWeek).toString().equals(&quot;closed&quot;) ? @string/closed_unfmt : hours.getHoursForDayOfWeek(dayOfWeek).toString()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="07:00-21:00" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<variable
name="item"
type="FilterWithValue&lt;MultipleChoiceFilterValue&gt;" />
<variable
name="showingAll"
type="boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView17"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Networks" />
<ImageButton
android:id="@+id/btnEdit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_edit"
android:contentDescription="@string/edit" />
<TextView
android:id="@+id/textView26"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:text="@{item.value.all ? @string/all_selected : @string/number_selected(item.value.values.size())}"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView17"
tools:text="4 selected" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Gerätesprache verwenden</item>
<item>Englisch</item>
<item>Deutsch</item>
</string-array>
<string-array name="pref_darkmode_names">
<item>Geräteeinstellung verwenden</item>
<item>immer an</item>
<item>immer aus</item>
</string-array>
</resources>

View File

@@ -10,6 +10,8 @@
<string name="hours">Öffnungszeiten</string>
<string name="open_247"><![CDATA[<b>24 Stunden geöffnet</b>]]></string>
<string name="closed"><![CDATA[<b>Geschlossen</b>]]></string>
<string name="closed_unfmt">Geschlossen</string>
<string name="holiday">Feiertag</string>
<string name="open_closesat"><![CDATA[<b>Geöffnet</b> · Schließt um %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Geschlossen</b> · Öffnet um %s]]></string>
<string name="cost">Kosten</string>
@@ -23,8 +25,8 @@
<string name="realtime_data_source">Quelle Echtzeitdaten (beta): %s</string>
<string name="go_to_goingelectric">Quelle: goingelectric.de</string>
<string name="search">Suche</string>
<string name="menu_map">Map</string>
<string name="menu_favs">Favorites</string>
<string name="menu_map">Karte</string>
<string name="menu_favs">Favoriten</string>
<string name="menu_filter">Filtern</string>
<string name="not_implemented">noch nicht implementiert</string>
<string name="about">Über EVMap</string>
@@ -47,4 +49,60 @@
<string name="filter_free">Nur kostenlose Ladesäulen</string>
<string name="filter_min_power">Minimale Leistung</string>
<string name="filter_free_parking">Nur Ladesäulen mit kostenlosem Parkplatz</string>
<string name="filter_min_connectors">Mindestzahl Anschlüsse</string>
<string name="filter_connectors">Anschlüsse</string>
<string name="plug_type_1">Typ 1</string>
<string name="plug_type_2">Typ 2</string>
<string name="plug_type_3">Typ 3a</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_supercharger">Tesla Supercharger</string>
<string name="plug_cee_blau">CEE Blau</string>
<string name="plug_cee_rot">CEE Rot</string>
<string name="all">alle</string>
<string name="none">keine</string>
<string name="show_more">mehr…</string>
<string name="show_less">weniger…</string>
<string name="favorites_empty_state">Wenn du Ladestationen als Favorit markierst, tauchen sie hier auf.</string>
<string name="donate">Spenden</string>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string name="donation_successful">Vielen Dank! ❤️</string>
<string name="donation_failed">Etwas ist schiefgelaufen. 😕</string>
<string name="map_type_normal">Standard</string>
<string name="map_type_satellite">Satellit</string>
<string name="map_type_terrain">Gelände</string>
<string name="map_type">Kartentyp</string>
<string name="map_details">Kartendetails</string>
<string name="map_traffic">Verkehr</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filter aktiv</string>
<string name="filters_activated">Filter aktiviert</string>
<string name="filters_deactivated">Filter deaktiviert</string>
<string name="menu_edit_filters">Filter bearbeiten…</string>
<string name="go_to_chargeprice">Preisvergleich</string>
<string name="fault_report">Störungsmeldung</string>
<string name="fault_report_date">Störungsmeldung (Letztes Update: %s)</string>
<string name="filter_networks">Verbünde</string>
<string name="filter_chargecards">Ladetarife</string>
<string name="all_selected">Alle ausgewählt</string>
<string name="number_selected">%d ausgewählt</string>
<string name="edit">bearbeiten</string>
<string name="cancel">Abbrechen</string>
<string name="ok">OK</string>
<string name="pref_language">Sprache</string>
<string name="pref_language_summary">App-Sprache ändern</string>
<string name="pref_darkmode">Dunkles Design</string>
<string name="pref_darkmode_summary">Einstellen, wann der Nachtmodus genutzt wird</string>
<string name="connection_error">Ladesäulen konnten nicht geladen werden</string>
<string name="retry">Wiederholen</string>
<string name="filter_open_247">24 Stunden geöffnet</string>
<string name="filter_barrierfree">Ohne Vertrag / Registrierung nutzbar</string>
<string name="filter_exclude_faults">Ladesäulen mit Störung ausschließen</string>
<string name="charge_cards">Ladetarife</string>
<string name="and_n_others">und %d weitere</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d kompatibler Ladetarif</item>
<item quantity="other">%d kompatible Ladetarife</item>
</plurals>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="AppTheme.Base">
<item name="android:navigationBarColor">@android:color/white</item>
</style>
</resources>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Device default</item>
<item>English</item>
<item>German</item>
</string-array>
<string-array name="pref_language_values" tranlatable="false">
<item>default</item>
<item>en</item>
<item>de</item>
</string-array>
<string-array name="pref_darkmode_names">
<item>Device default</item>
<item>always on</item>
<item>always off</item>
</string-array>
<string-array name="pref_darkmode_values" tranlatable="false">
<item>default</item>
<item>on</item>
<item>off</item>
</string-array>
</resources>

View File

@@ -7,9 +7,10 @@
<color name="charger_100kw">#ffeb3b</color>
<color name="charger_43kw">#ff9800</color>
<color name="charger_20kw">#03a9f4</color>
<color name="charger_11kw">#607d8b</color>
<color name="charger_low">#9e9e9e</color>
<color name="charger_11kw">#9e9e9e</color>
<color name="charger_low">#607d8b</color>
<color name="available">#4caf50</color>
<color name="unavailable">#f44336</color>
<color name="unknown">#9e9e9e</color>
<color name="status_bar_scrim">#C3000000</color>
</resources>

View File

@@ -3,4 +3,8 @@
<dimen name="peek_height">72dp</dimen>
<dimen name="gallery_height">200dp</dimen>
<dimen name="gallery_height_with_margin">208dp</dimen>
<dimen name="detail_corner_radius">8dp</dimen>
<dimen name="detail_corner_radius_negative">-8dp</dimen>
<dimen name="expand_toggle_padding_large">16dp</dimen>
<dimen name="expand_toggle_padding_small">8dp</dimen>
</resources>

View File

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

View File

@@ -11,6 +11,8 @@
<string name="closed"><![CDATA[<b>Closed</b>]]></string>
<string name="open_closesat"><![CDATA[<b>Open</b> · Closes at %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Closed</b> · Opens at %s]]></string>
<string name="closed_unfmt">Closed</string>
<string name="holiday">Holiday</string>
<string name="cost">Cost</string>
<string name="cost_detail"><![CDATA[<b>Charging:</b> %s · <b>Parking:</b> %s]]></string>
<string name="free">Free</string>
@@ -46,4 +48,60 @@
<string name="filter_free">Only free chargers</string>
<string name="filter_min_power">Minimum power</string>
<string name="filter_free_parking">Only chargers with free parking</string>
<string name="filter_min_connectors">Minimum number of connectors</string>
<string name="filter_connectors">Connectors</string>
<string name="plug_type_1">Type 1</string>
<string name="plug_type_2">Type 2</string>
<string name="plug_type_3">Type 3a</string>
<string name="plug_ccs">CCS</string>
<string name="plug_schuko">Schuko</string>
<string name="plug_chademo">CHAdeMO</string>
<string name="plug_supercharger">Tesla Supercharger</string>
<string name="plug_cee_blau">CEE Blue</string>
<string name="plug_cee_rot">CEE Red</string>
<string name="all">all</string>
<string name="none">none</string>
<string name="show_more">more…</string>
<string name="show_less">less…</string>
<string name="favorites_empty_state">If you add chargers as favorites, they will show up here.</string>
<string name="donate">Donate</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 30% off every donation.</string>
<string name="donation_successful">Thank you! ❤️</string>
<string name="donation_failed">Something went wrong. 😕</string>
<string name="map_type_normal">Default</string>
<string name="map_type_satellite">Satellite</string>
<string name="map_type_terrain">Terrain</string>
<string name="map_type">Map type</string>
<string name="map_details">Map details</string>
<string name="map_traffic">Traffic</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filters active</string>
<string name="filters_activated">Filters activated</string>
<string name="filters_deactivated">Filters deactivated</string>
<string name="menu_edit_filters">Edit filters…</string>
<string name="go_to_chargeprice">Compare prices</string>
<string name="fault_report">Fault report</string>
<string name="fault_report_date">Fault report (last update: %s)</string>
<string name="filter_networks">Networks</string>
<string name="filter_chargecards">Payment methods</string>
<string name="all_selected">All selected</string>
<string name="number_selected">%d selected</string>
<string name="edit">edit</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<string name="pref_language">Language</string>
<string name="pref_language_summary">Change the app language</string>
<string name="pref_darkmode">Dark mode</string>
<string name="pref_darkmode_summary">Set when dark mode is activated</string>
<string name="connection_error">Could not load charging stations</string>
<string name="retry">Retry</string>
<string name="filter_open_247">Available 24/7</string>
<string name="filter_barrierfree">Usable without registration</string>
<string name="filter_exclude_faults">Exclude chargers with reported faults</string>
<string name="charge_cards">Payment methods</string>
<string name="and_n_others">and %d others</string>
<plurals name="charge_cards_compatible_num">
<item quantity="one">%d compatible payment method</item>
<item quantity="other">%d compatible payment methods</item>
</plurals>
</resources>

View File

@@ -17,6 +17,10 @@
<style name="AppTheme" parent="AppTheme.Base" />
<style name="AppTheme.LaunchScreen">
<item name="android:windowBackground">@drawable/launch_screen</item>
</style>
<style name="FullScreenDialogStyle" parent="AppTheme">
<item name="android:windowFullscreen">false</item>
<item name="android:windowIsFloating">false</item>

View File

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

View File

@@ -10,6 +10,21 @@
android:summaryOff="@string/pref_navigate_use_maps_off"
android:defaultValue="true" />
<ListPreference
android:key="language"
android:title="@string/pref_language"
android:entries="@array/pref_language_names"
android:entryValues="@array/pref_language_values"
android:defaultValue="default"
android:summary="@string/pref_language_summary" />
<ListPreference
android:key="darkmode"
android:title="@string/pref_darkmode"
android:entries="@array/pref_darkmode_names"
android:entryValues="@array/pref_darkmode_values"
android:defaultValue="default"
android:summary="@string/pref_darkmode_summary" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,16 @@
package net.vonforst.evmap
import net.vonforst.evmap.api.goingelectric.GoingElectricApiTest
import okhttp3.mockwebserver.MockResponse
import java.net.HttpURLConnection
val notFoundResponse: MockResponse =
MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
fun okResponse(file: String): MockResponse {
val body = readResource(file) ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody(body)
}
private fun readResource(s: String) =
GoingElectricApiTest::class.java.getResource(s)?.readText()

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.okResponse
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -37,9 +38,7 @@ class NewMotionAvailabilityDetectorTest {
when (urlHead) {
"ge/chargepoints" -> {
val id = request.requestUrl.queryParameter("ge_id")
val body = readResource("/chargers/$id.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/chargers/$id.json")
}
"nm/markers" -> {
val urlTail = segments.subList(2, segments.size).joinToString("/")
@@ -48,16 +47,11 @@ class NewMotionAvailabilityDetectorTest {
"9.444284/9.644283999999999/54.376699/54.576699000000005" -> 18284
else -> -1
}
val body =
readResource("/newmotion/$id/markers.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/newmotion/$id/markers.json")
}
"nm/locations" -> {
val id = segments.last()
val body = readResource("/newmotion/$id.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/newmotion/$id.json")
}
else -> return notFoundResponse
}

View File

@@ -0,0 +1,105 @@
package net.vonforst.evmap.api.goingelectric
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.notFoundResponse
import net.vonforst.evmap.okResponse
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GoingElectricApiTest {
val api: GoingElectricApi
val webServer = MockWebServer()
init {
webServer.start()
val apikey = ""
val baseurl = webServer.url("/ge/").toString()
api = GoingElectricApi.create(apikey, baseurl)
webServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val segments = request.requestUrl.pathSegments()
val urlHead = segments.subList(0, 2).joinToString("/")
when (urlHead) {
"ge/chargepoints" -> {
val id = request.requestUrl.queryParameter("ge_id")
if (id != null) {
return okResponse("/chargers/$id.json")
} else {
val freeparking =
request.requestUrl.queryParameter("freeparking")!!.toBoolean()
val freecharging =
request.requestUrl.queryParameter("freecharging")!!.toBoolean()
return if (freeparking && freecharging) {
okResponse("/chargers/list-empty.json")
} else if (freecharging) {
okResponse("/chargers/list.json")
} else {
okResponse("/chargers/list-startkey.json")
}
}
}
else -> return notFoundResponse
}
}
}
}
@Test
fun testLoadChargepointDetail() {
val response = api.getChargepointDetail(2105).execute()
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(1, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(2105, charger.id)
}
@Test
fun testLoadChargepointList() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 11f, freecharging = true)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(41161, charger.id)
}
@Test
fun testLoadChargepointListEmpty() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 11f, freeparking = true, freecharging = true)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(0, body.chargelocations.size)
}
@Test
fun testLoadChargepointListStartkey() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 1f)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(2, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(41161, charger.id)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
{
"status": "ok",
"startkey": false,
"chargelocations": []
}

View File

@@ -0,0 +1,61 @@
{
"status": "ok",
"chargelocations": [
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
}
],
"ge_id": 41161,
"name": "BMW Autohaus B&K",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "21073",
"street": "Buxtehuder Straße 112"
},
"coordinates": {
"lat": 53.469542,
"lng": 9.964063
},
"network": "Digital Energy Solutions",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/BMW-Autohaus-B-K-Buxtehuder-Strasse-112/41161/",
"fault_report": false,
"verified": false
},
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
},
{
"type": "Schuko",
"power": 3.6,
"count": 2
}
],
"ge_id": 41226,
"name": "Saseler Chaussee",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "22393",
"street": "Saseler Chaussee 94a"
},
"coordinates": {
"lat": 53.644021,
"lng": 10.099783
},
"network": "Stromnetz Hamburg",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/Saseler-Chaussee-Saseler-Chaussee-94a/41226/",
"fault_report": false,
"verified": false
}
],
"startkey": 2
}

View File

@@ -0,0 +1,60 @@
{
"status": "ok",
"chargelocations": [
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
}
],
"ge_id": 41161,
"name": "BMW Autohaus B&K",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "21073",
"street": "Buxtehuder Straße 112"
},
"coordinates": {
"lat": 53.469542,
"lng": 9.964063
},
"network": "Digital Energy Solutions",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/BMW-Autohaus-B-K-Buxtehuder-Strasse-112/41161/",
"fault_report": false,
"verified": false
},
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
},
{
"type": "Schuko",
"power": 3.6,
"count": 2
}
],
"ge_id": 41226,
"name": "Saseler Chaussee",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "22393",
"street": "Saseler Chaussee 94a"
},
"coordinates": {
"lat": 53.644021,
"lng": 10.099783
},
"network": "Stromnetz Hamburg",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/Saseler-Chaussee-Saseler-Chaussee-94a/41226/",
"fault_report": false,
"verified": false
}
]
}

View File

@@ -1,14 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.71'
ext.kotlin_version = '1.3.72'
ext.about_libs_version = '8.1.1'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.0-beta03'
classpath 'com.android.tools.build:gradle:4.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
@@ -25,6 +25,9 @@ allprojects {
google()
jcenter()
maven { url 'https://jitpack.io' }
flatDir {
dirs 'libs'
}
}
}