Compare commits
214 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385aa46686 | ||
|
|
3c9a0b3a50 | ||
|
|
761a690d76 | ||
|
|
7356b8a1be | ||
|
|
0c0a1f59a6 | ||
|
|
876d2759dd | ||
|
|
ae489aa6ef | ||
|
|
d21ac0a781 | ||
|
|
b4baa87e10 | ||
|
|
05ffe1c265 | ||
|
|
8d68dd5366 | ||
|
|
dbcde7cf7a | ||
|
|
4ea37ee10d | ||
|
|
ec7b08338c | ||
|
|
dc4c2394f9 | ||
|
|
4b4ee807b0 | ||
|
|
c55720edc7 | ||
|
|
57ba8db799 | ||
|
|
3151d74d1a | ||
|
|
af0fb6762d | ||
|
|
5571c33ebe | ||
|
|
388952ae28 | ||
|
|
94934aa130 | ||
|
|
63eddde837 | ||
|
|
a9f735d783 | ||
|
|
2dcd04c86e | ||
|
|
9ed23c7000 | ||
|
|
79a7200f7b | ||
|
|
0c315079ca | ||
|
|
7943d6669c | ||
|
|
a781591510 | ||
|
|
b8ba06bab1 | ||
|
|
955b64ec66 | ||
|
|
117ab0f159 | ||
|
|
bac3fd1048 | ||
|
|
7cc07ca511 | ||
|
|
80743fab7d | ||
|
|
c423974ffd | ||
|
|
b2d365755f | ||
|
|
9df24081d4 | ||
|
|
255001b768 | ||
|
|
55af84b7de | ||
|
|
4f6f09dc83 | ||
|
|
7f6d0c1391 | ||
|
|
96b60d0f49 | ||
|
|
2824f0b5c3 | ||
|
|
af0921ed20 | ||
|
|
a5b55479cb | ||
|
|
a93bacd9b3 | ||
|
|
9d7278e0e2 | ||
|
|
f6d9c615a0 | ||
|
|
a8ee3f5b7d | ||
|
|
826b4f89f1 | ||
|
|
5675d065e3 | ||
|
|
3e3531551d | ||
|
|
5d7d881729 | ||
|
|
23c73e3d7e | ||
|
|
7835aa8d78 | ||
|
|
f06b712090 | ||
|
|
317695954d | ||
|
|
24cfd1c10b | ||
|
|
775faa2f55 | ||
|
|
08bd2bdf5a | ||
|
|
90254915e3 | ||
|
|
b7f56ecff4 | ||
|
|
fa3910d3c8 | ||
|
|
4500c55560 | ||
|
|
a493e1a548 | ||
|
|
ddaab42e45 | ||
|
|
9f50341ab7 | ||
|
|
9966b44a76 | ||
|
|
d44b2206d2 | ||
|
|
f61082f491 | ||
|
|
f58d96c939 | ||
|
|
29aedfa3d9 | ||
|
|
8331f92f10 | ||
|
|
123680d3e8 | ||
|
|
0f6b45d745 | ||
|
|
69faa94f18 | ||
|
|
70805b7960 | ||
|
|
56453b0658 | ||
|
|
975d95e37e | ||
|
|
ba34cd016a | ||
|
|
590b16aa49 | ||
|
|
5fe8d0cab4 | ||
|
|
9d7b181410 | ||
|
|
128532aac6 | ||
|
|
486854f56c | ||
|
|
1e30db5cd1 | ||
|
|
aad386ab04 | ||
|
|
e2bcf8d1cd | ||
|
|
f56fad1282 | ||
|
|
adb4d938cc | ||
|
|
b773f65912 | ||
|
|
de335b18d8 | ||
|
|
6c8380b8ce | ||
|
|
81afdca19d | ||
|
|
14e03ba6dd | ||
|
|
abe12b45c3 | ||
|
|
23387ae371 | ||
|
|
25f466b6d7 | ||
|
|
6692b21bf9 | ||
|
|
5959fe8be4 | ||
|
|
00f4c13fcc | ||
|
|
47054d470b | ||
|
|
d10192cae1 | ||
|
|
e1b90955c3 | ||
|
|
d249bf47c7 | ||
|
|
738dcd5f8d | ||
|
|
ad4f32ec32 | ||
|
|
4d03107ae7 | ||
|
|
0e93e310bf | ||
|
|
6cb8940696 | ||
|
|
dad30eb51e | ||
|
|
abf6a2b933 | ||
|
|
2c5685d918 | ||
|
|
b61e57b022 | ||
|
|
e6428cc8db | ||
|
|
6302006a35 | ||
|
|
ab93577a98 | ||
|
|
98b695ed4b | ||
|
|
ed8cb50b08 | ||
|
|
88d89c2760 | ||
|
|
80c25cb416 | ||
|
|
81c8e54dd2 | ||
|
|
8c01ee1581 | ||
|
|
e8db5acfbf | ||
|
|
f6bb3c03ba | ||
|
|
134f3856b9 | ||
|
|
4974cc6d83 | ||
|
|
edd072b83a | ||
|
|
35ddda5bfe | ||
|
|
8b241e3f6f | ||
|
|
b3c5fe788d | ||
|
|
6fd737f6e9 | ||
|
|
08cd4eb849 | ||
|
|
ff75594b37 | ||
|
|
2576bc4854 | ||
|
|
b2c29b647b | ||
|
|
2167a63321 | ||
|
|
fb0a2cfa1c | ||
|
|
07be77c573 | ||
|
|
ae0a84db4c | ||
|
|
dc5ffb148d | ||
|
|
066b7c085e | ||
|
|
4ae16df064 | ||
|
|
17a40127e6 | ||
|
|
31ad748796 | ||
|
|
fe4db38798 | ||
|
|
6c2243078b | ||
|
|
71f1ee8d7b | ||
|
|
ab0c37cb82 | ||
|
|
65189cd798 | ||
|
|
630178bfcf | ||
|
|
bcee975124 | ||
|
|
04fc17d73c | ||
|
|
139c02ef70 | ||
|
|
88a8520f27 | ||
|
|
4f3157a0ac | ||
|
|
17d57729b3 | ||
|
|
1f3df2e0bf | ||
|
|
e2e95ce85d | ||
|
|
d79b554dcc | ||
|
|
98e91ea3db | ||
|
|
b8c8245978 | ||
|
|
fd1f05888a | ||
|
|
2e4167689d | ||
|
|
8a2ad55dd6 | ||
|
|
44ce0cfaea | ||
|
|
70f964549e | ||
|
|
c045eed41a | ||
|
|
3ded108c3c | ||
|
|
b3eb1e31e8 | ||
|
|
7eeb10faca | ||
|
|
4208e1a4b5 | ||
|
|
54004f14b5 | ||
|
|
8eabff4888 | ||
|
|
d5b5337aeb | ||
|
|
913d8a00cf | ||
|
|
fc5003cd31 | ||
|
|
ff96e49ead | ||
|
|
36bd74e091 | ||
|
|
3d0dc16f49 | ||
|
|
41b374350b | ||
|
|
fc72044b82 | ||
|
|
e96fcd4a88 | ||
|
|
36f34bde1e | ||
|
|
624c5d8f92 | ||
|
|
7fcb187dda | ||
|
|
7188a2aa64 | ||
|
|
cf6c662832 | ||
|
|
4ceef7997d | ||
|
|
3f79bdd125 | ||
|
|
fb279f90c5 | ||
|
|
6f35ced260 | ||
|
|
c967bab524 | ||
|
|
6bf80e2b49 | ||
|
|
d97cb4b9fb | ||
|
|
17eaeb99da | ||
|
|
beebbe1c1b | ||
|
|
0a2bbd5fb4 | ||
|
|
7f1f4b67a1 | ||
|
|
d5e29a5112 | ||
|
|
77f478c9e0 | ||
|
|
1008a2c2cd | ||
|
|
2219e2fe27 | ||
|
|
8ce145a9af | ||
|
|
b799dae28b | ||
|
|
07a482a6b6 | ||
|
|
4f1253b201 | ||
|
|
8bc4a7ae40 | ||
|
|
d686becfe4 | ||
|
|
a686c51b32 | ||
|
|
382ead9e08 |
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: johan12345
|
||||
custom: 'https://paypal.me/johan98'
|
||||
@@ -1,5 +1,5 @@
|
||||
language: java
|
||||
dist: trusty
|
||||
dist: focal
|
||||
env:
|
||||
global:
|
||||
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
|
||||
|
||||
51
README.md
@@ -1,4 +1,4 @@
|
||||
EVMap [](https://travis-ci.org/johan12345/EVMap)
|
||||
EVMap [](https://app.travis-ci.com/johan12345/EVMap)
|
||||
=====
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
|
||||
@@ -15,10 +15,11 @@ Features
|
||||
|
||||
- [Material Design](https://material.io/)
|
||||
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) and [Open Charge Map](https://openchargemap.org) directories
|
||||
- Realtime availability information (beta)
|
||||
- Search places
|
||||
- Realtime availability information (only in Europe)
|
||||
- Search for places
|
||||
- Advanced filtering options, including saved filter profiles
|
||||
- Favorites list, also with availability information
|
||||
- Charging price comparison, powered by [Chargeprice.app](https://chargeprice.app)
|
||||
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
|
||||
- Android Auto integration
|
||||
- No ads, fully open source
|
||||
- Compatible with Android 5.0 and above
|
||||
@@ -27,38 +28,22 @@ Features
|
||||
Screenshots
|
||||
-----------
|
||||
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/01_main.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/02_detail.png" width=250 alt="Screenshot 2"/>
|
||||
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
|
||||
|
||||
Development setup
|
||||
-----------------
|
||||
|
||||
The App is developed using Android Studio.
|
||||
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
|
||||
the Git repository and open the project with Android Studio.
|
||||
|
||||
For testing the app, you need to obtain free API Keys for the
|
||||
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/),
|
||||
the [Chargeprice API](https://github.com/chargeprice/chargeprice-api-docs),
|
||||
the [OpenChargeMap API](https://openchargemap.org/site/profile/appedit),
|
||||
as well as for [Google APIs](https://console.developers.google.com/)
|
||||
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These API keys need to be put into the
|
||||
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
|
||||
following content:
|
||||
The only exception is that you need to obtain some free API keys for the different data sources that
|
||||
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
|
||||
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
|
||||
features and how they can be obtained in our [documentation page](doc/api_keys.md).
|
||||
|
||||
```xml
|
||||
<resources>
|
||||
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
|
||||
insert your Google Maps key here
|
||||
</string>
|
||||
<string name="mapbox_key" translatable="false">
|
||||
insert your Mapbox key here
|
||||
</string>
|
||||
<string name="goingelectric_key" translatable="false">
|
||||
insert your GoingElectric key here
|
||||
</string>
|
||||
<string name="chargeprice_key" translatable="false">
|
||||
insert your Chargeprice key here
|
||||
</string>
|
||||
<string name="openchargemap_key" translatable="false">
|
||||
insert your OpenChargeMap key here
|
||||
</string>
|
||||
</resources>
|
||||
```
|
||||
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
|
||||
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
|
||||
and should run on devices without Google Play Services.
|
||||
|
||||
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
|
||||
app.
|
||||
|
||||
BIN
_img/screenshots/android_auto/de/11_android_auto_map.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
_img/screenshots/android_auto/de/12_android_auto_detail.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
_img/screenshots/android_auto/de/13_android_auto_prices.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
_img/screenshots/android_auto/de/14_vehicle_data.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
_img/screenshots/android_auto/en/11_android_auto_map.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
_img/screenshots/android_auto/en/12_android_auto_detail.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
_img/screenshots/android_auto/en/13_android_auto_prices.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
_img/screenshots/android_auto/en/14_vehicle_data.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 909 KiB |
|
Before Width: | Height: | Size: 943 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 158 KiB |
BIN
_img/screenshots/phone/de/google/01_map.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
_img/screenshots/phone/de/google/02_detail.png
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
_img/screenshots/phone/de/google/03_prices.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
_img/screenshots/phone/de/google/04_favorites.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
_img/screenshots/phone/de/google/05_filters.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
_img/screenshots/phone/de/mapbox/01_map.png
Normal file
|
After Width: | Height: | Size: 875 KiB |
BIN
_img/screenshots/phone/de/mapbox/02_detail.png
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
_img/screenshots/phone/de/mapbox/03_prices.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
_img/screenshots/phone/de/mapbox/04_favorites.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
_img/screenshots/phone/de/mapbox/05_filters.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
_img/screenshots/phone/en/google/01_map.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
_img/screenshots/phone/en/google/02_detail.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
BIN
_img/screenshots/phone/en/google/03_prices.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
_img/screenshots/phone/en/google/04_favorites.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
_img/screenshots/phone/en/google/05_filters.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
_img/screenshots/phone/en/mapbox/01_map.png
Normal file
|
After Width: | Height: | Size: 864 KiB |
BIN
_img/screenshots/phone/en/mapbox/02_detail.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
BIN
_img/screenshots/phone/en/mapbox/03_prices.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
_img/screenshots/phone/en/mapbox/04_favorites.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
_img/screenshots/phone/en/mapbox/05_filters.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -6,15 +6,15 @@ apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdkVersion 31
|
||||
buildToolsVersion "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.vonforst.evmap"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 52
|
||||
versionName "0.8.4"
|
||||
targetSdkVersion 31
|
||||
versionCode 68
|
||||
versionName "1.2.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -89,6 +89,9 @@ android {
|
||||
variant.resValue "string", "google_maps_key", googleMapsKey
|
||||
}
|
||||
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
|
||||
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
|
||||
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
|
||||
}
|
||||
if (mapboxKey != null) {
|
||||
variant.resValue "string", "mapbox_key", mapboxKey
|
||||
}
|
||||
@@ -100,35 +103,39 @@ android {
|
||||
variant.resValue "string", "chargeprice_key", chargepriceKey
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'NullSafeMutableLiveData'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.3.0'
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation "androidx.activity:activity-ktx:1.2.3"
|
||||
implementation "androidx.fragment:fragment-ktx:1.3.4"
|
||||
implementation 'androidx.appcompat:appcompat:1.4.0'
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
|
||||
implementation "androidx.activity:activity-ktx:1.4.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.4.0"
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'com.google.android.material:material:1.3.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.0'
|
||||
implementation 'androidx.browser:browser:1.3.0'
|
||||
implementation 'com.google.android.material:material:1.5.0-rc01'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.12.0'
|
||||
implementation 'com.squareup.moshi:moshi-adapters:1.12.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
|
||||
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
|
||||
implementation 'moe.banana:moshi-jsonapi:3.5.0'
|
||||
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
|
||||
implementation 'io.coil-kt:coil:1.1.0'
|
||||
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
|
||||
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
|
||||
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
|
||||
implementation 'com.airbnb.android:lottie:3.4.0'
|
||||
implementation 'com.airbnb.android:lottie:4.1.0'
|
||||
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
|
||||
implementation 'com.mapzen.android:lost:3.0.2'
|
||||
implementation 'com.google.guava:guava:29.0-android'
|
||||
@@ -136,46 +143,34 @@ dependencies {
|
||||
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
|
||||
|
||||
// Android Auto
|
||||
googleImplementation 'androidx.car.app:app:1.0.0'
|
||||
googleImplementation 'androidx.car.app:app:1.2.0-alpha02'
|
||||
googleImplementation 'androidx.car.app:app-projected:1.2.0-alpha02'
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = '95ddd6c083'
|
||||
def anyMapsVersion = '751daec281'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.0.1'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
|
||||
|
||||
// Google Maps v3 Beta
|
||||
googleImplementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
|
||||
googleImplementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
|
||||
googleImplementation 'com.android.volley:volley:1.2.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
|
||||
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
|
||||
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
|
||||
googleImplementation 'com.google.code.gson:gson:2.8.6'
|
||||
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
|
||||
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
// Google Places
|
||||
implementation 'com.google.android.libraries.places:places:2.5.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
|
||||
|
||||
// Mapbox places (autocomplete)
|
||||
// forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011
|
||||
implementation('com.github.johan12345.mapbox-plugins-android:mapbox-android-plugin-places-v9:922bf877f6') {
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
|
||||
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
|
||||
}
|
||||
// Mapbox Geocoding
|
||||
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
|
||||
|
||||
// navigation library
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.3.1"
|
||||
def lifecycle_version = "2.4.0"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// room library
|
||||
def room_version = "2.3.0"
|
||||
def room_version = "2.4.0"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
@@ -185,17 +180,23 @@ dependencies {
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
// ACRA (crash reporting)
|
||||
def acraVersion = "5.8.4"
|
||||
implementation("ch.acra:acra-mail:$acraVersion")
|
||||
implementation("ch.acra:acra-dialog:$acraVersion")
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
// debug tools
|
||||
implementation 'com.facebook.stetho:stetho:1.5.1'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
|
||||
//noinspection GradleDependency
|
||||
testImplementation 'org.json:json:20080701'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.12.0"
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
}
|
||||
|
||||
107
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="198.3471"
|
||||
android:viewportHeight="198.3471">
|
||||
<group
|
||||
android:translateX="3.1735537"
|
||||
android:translateY="3.1735537">
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:pathData="M106.2,74.3h-7"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#212121"
|
||||
android:fillAlpha="0.2" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:pathData="M106.2,60.3c0,0 -17.5,0 -17.5,0"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#212121"
|
||||
android:fillAlpha="0.2" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:pathData="M93.9,79.5L88.7,79.5"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillAlpha="0.2" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M94,79v16.2"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#212121"
|
||||
android:fillAlpha="0.2"
|
||||
android:strokeLineCap="round" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M106.2,60.3L99.2,74.3"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillAlpha="0.2"
|
||||
android:strokeLineCap="round" />
|
||||
</group>
|
||||
<group>
|
||||
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M106.2,74.3L93.9,95.2"
|
||||
android:strokeAlpha="0.2"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillAlpha="0.2"
|
||||
android:strokeLineCap="round" />
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M67.6,120.6L65.7,104l-2.9,0.3l1.9,16.6L67.6,120.6zM77.9,119.4l-1.9,-16.6l-2.9,0.3l1.9,16.6L77.9,119.4z"
|
||||
android:fillColor="#808080" />
|
||||
<path
|
||||
android:pathData="M83.3,142c-0.9,1.1 -1.6,1.8 -1.7,1.9c-2.6,2.1 -4.7,2.7 -6.4,1.9c-3,-1.5 -2.8,-7.1 -2.7,-7.7l2.1,0.1c-0.1,1.6 0.2,5 1.6,5.7c0.8,0.4 2.2,-0.1 4,-1.6l0,0c0,0 5.8,-5.8 4.6,-10.4c-1.4,-5.5 5,-13.4 7.1,-16.1l0.3,-0.3l1.7,1.3l-0.3,0.4c-6.5,8 -7.2,12.1 -6.7,14.2C87.9,135.4 85.2,139.7 83.3,142z"
|
||||
android:fillColor="#9e9e9e" />
|
||||
<path
|
||||
android:pathData="M61.2,120.4l0.8,6.8l6.3,4.2l8.5,-0.9l5.2,-5.5l-0.8,-6.8L61.2,120.4z"
|
||||
android:fillColor="#9e9e9e" />
|
||||
<path
|
||||
android:pathData="M76.7,130.5l-8.5,0.9l1.8,7.5l6.7,-0.8L76.7,130.5L76.7,130.5zM82.8,112.5l0.7,6.2l-24.4,2.8l-0.7,-6.2L82.8,112.5z"
|
||||
android:fillColor="#666666" />
|
||||
<path
|
||||
android:pathData="M101.9,44.1c-17.5,0 -31.7,14.2 -31.7,31.7c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5C133.6,58.2 119.4,44.1 101.9,44.1z"
|
||||
android:fillColor="#737373" />
|
||||
<path
|
||||
android:pathData="M101.9,44.8c17.4,0 31.5,14 31.7,31.3c0,-0.1 0,-0.2 0,-0.3c0,-17.5 -14.2,-31.7 -31.7,-31.7S70.2,58.2 70.2,75.8c0,0.1 0,0.2 0,0.3C70.4,58.8 84.5,44.8 101.9,44.8L101.9,44.8z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.2" />
|
||||
<path
|
||||
android:pathData="M103.8,145.5c-0.1,1 -0.9,1.7 -1.9,1.7s-1.8,-0.7 -1.9,-1.7c-3.1,-34 -29.6,-46.5 -29.8,-70.1c0,0.2 0,0.3 0,0.5c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5c0,-0.2 0,-0.3 0,-0.5C133.4,99 106.9,111.5 103.8,145.5L103.8,145.5z"
|
||||
android:fillColor="#303030"
|
||||
android:fillAlpha="0.2" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M94.6,60.3v19.2h5.2v15.7l12.2,-21h-7l7,-14C112.1,60.3 94.6,60.3 94.6,60.3z"
|
||||
android:strokeAlpha="0.45"
|
||||
android:fillAlpha="0.45" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,49 +1,5 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.mapbox.geojson.BoundingBox
|
||||
import com.mapbox.geojson.Point
|
||||
import com.mapbox.mapboxsdk.plugins.places.autocomplete.PlaceAutocomplete
|
||||
import com.mapbox.mapboxsdk.plugins.places.autocomplete.model.PlaceOptions
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE
|
||||
import net.vonforst.evmap.viewmodel.PlaceWithBounds
|
||||
|
||||
|
||||
fun launchAutocomplete(fragment: Fragment) {
|
||||
val placeOptions = PlaceOptions.builder()
|
||||
.build(PlaceOptions.MODE_CARDS)
|
||||
|
||||
val intent = PlaceAutocomplete.IntentBuilder()
|
||||
.accessToken(fragment.getString(R.string.mapbox_key))
|
||||
.placeOptions(placeOptions)
|
||||
.build(fragment.requireActivity())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
|
||||
|
||||
// show keyboard
|
||||
val imm = fragment.requireContext()
|
||||
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.toggleSoftInput(0, 0)
|
||||
}
|
||||
|
||||
fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? {
|
||||
val place = PlaceAutocomplete.getPlace(intent) ?: return null
|
||||
val bbox = place.bbox()?.toLatLngBounds()
|
||||
val center = place.center()!!.toLatLng()
|
||||
return PlaceWithBounds(center, bbox)
|
||||
}
|
||||
|
||||
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
|
||||
return LatLngBounds(
|
||||
southwest().toLatLng(),
|
||||
northeast().toLatLng()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude())
|
||||
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))
|
||||
@@ -8,6 +8,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||
@@ -15,11 +16,17 @@ import net.vonforst.evmap.databinding.FragmentDonateBinding
|
||||
class DonateFragment : Fragment() {
|
||||
private lateinit var binding: FragmentDonateBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
binding = FragmentDonateBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
@@ -27,9 +34,8 @@ class DonateFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
|
||||
|
||||
val navController = findNavController()
|
||||
binding.toolbar.setupWithNavController(
|
||||
navController,
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class OnboardingViewPagerAdapter(fragment: Fragment) :
|
||||
FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 3
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> WelcomeFragment()
|
||||
1 -> IconsFragment()
|
||||
2 -> DataSourceSelectFragment()
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDonate"
|
||||
style="@style/Widget.MaterialComponents.Button.Icon"
|
||||
style="@style/Widget.Material3.Button.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
<string-array name="pref_map_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<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="donate_paypal">Mit PayPal spenden</string>
|
||||
</resources>
|
||||
@@ -6,6 +6,13 @@
|
||||
<string-array name="pref_map_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="pref_map_provider_default" translatable="false">mapbox</string>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
|
||||
<string name="donate_paypal">Donate with PayPal</string>
|
||||
|
||||
4
app/src/foss/res/xml/settings_variantspecific.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen>
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -5,8 +5,14 @@
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.car.app" />
|
||||
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
</queries>
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
@@ -21,6 +27,10 @@
|
||||
android:name="androidx.car.app.theme"
|
||||
android:resource="@style/CarAppTheme" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1" />
|
||||
|
||||
<service
|
||||
android:name=".auto.CarAppService"
|
||||
android:label="@string/app_name"
|
||||
@@ -36,8 +46,7 @@
|
||||
<service
|
||||
android:name=".auto.CarLocationService"
|
||||
android:foregroundServiceType="location"
|
||||
android:enabled="true" />
|
||||
|
||||
<activity android:name=".auto.PermissionActivity" />
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -5,10 +5,18 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.maps.MapsInitializer
|
||||
import com.google.android.libraries.places.api.Places
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.LocaleContextWrapper
|
||||
|
||||
fun init(context: Context) {
|
||||
Places.initialize(context, context.getString(R.string.google_maps_key));
|
||||
Places.initialize(context, context.getString(R.string.google_maps_key))
|
||||
|
||||
val localeContext = LocaleContextWrapper.wrap(
|
||||
context.applicationContext, PreferenceDataSource(context).language
|
||||
)
|
||||
MapsInitializer.initialize(localeContext, MapsInitializer.Renderer.LATEST, null)
|
||||
}
|
||||
|
||||
fun checkPlayServices(activity: Activity): Boolean {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.IBinder
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.validation.HostValidator
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.*
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.*
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
|
||||
|
||||
interface LocationAwareScreen {
|
||||
fun updateLocation(location: Location)
|
||||
}
|
||||
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
override fun createHostValidator(): HostValidator {
|
||||
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
|
||||
@@ -38,7 +40,6 @@ class CarAppService : androidx.car.app.CarAppService() {
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
var mapScreen: LocationAwareScreen? = null
|
||||
set(value) {
|
||||
@@ -47,6 +48,9 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
}
|
||||
private var location: Location? = null
|
||||
private var locationService: CarLocationService? = null
|
||||
private val hardwareMan: CarHardwareManager by lazy {
|
||||
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
}
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
|
||||
@@ -59,40 +63,55 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
locationService = null
|
||||
}
|
||||
}
|
||||
private var serviceBound = false
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
return if (locationPermissionGranted()) {
|
||||
WelcomeScreen(carContext, this)
|
||||
} else {
|
||||
PermissionScreen(carContext, this)
|
||||
}
|
||||
return WelcomeScreen(carContext, this)
|
||||
}
|
||||
|
||||
private fun locationPermissionGranted() =
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
|
||||
|
||||
private val locationReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
|
||||
val mapScreen = this@EVMapSession.mapScreen
|
||||
if (location != null && mapScreen != null) {
|
||||
mapScreen.updateLocation(location)
|
||||
}
|
||||
this@EVMapSession.location = location
|
||||
updateLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLocation(location: Location?) {
|
||||
val mapScreen = mapScreen
|
||||
if (location != null && mapScreen != null) {
|
||||
mapScreen.updateLocation(location)
|
||||
}
|
||||
this.location = location
|
||||
}
|
||||
|
||||
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
|
||||
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
|
||||
updateLocation(loc.location.value)
|
||||
|
||||
// we successfully received a location from the car hardware,
|
||||
// so we don't need the smartphone location anymore.
|
||||
unbindLocationService()
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
fun bindLocationService() {
|
||||
if (!locationPermissionGranted()) return
|
||||
cas.bindService(
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carSensors.addCarHardwareLocationListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
exec,
|
||||
::onCarHardwareLocationReceived
|
||||
)
|
||||
}
|
||||
serviceBound = cas.bindService(
|
||||
Intent(cas, CarLocationService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
@@ -100,10 +119,18 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
private fun onStop() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
|
||||
}
|
||||
unbindLocationService()
|
||||
}
|
||||
|
||||
private fun unbindLocationService() {
|
||||
locationService?.let { service ->
|
||||
service.removeLocationUpdates()
|
||||
locationService?.removeLocationUpdates()
|
||||
if (serviceBound) {
|
||||
cas.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
app/src/google/java/net/vonforst/evmap/auto/CarModels.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
/**
|
||||
* This file lists known mappings between the vehicle model provided by Android Auto's CarInfo API
|
||||
* and human-readable vehicle models as listed by Chargeprice in their vehicle database.
|
||||
*/
|
||||
|
||||
private val models = mapOf(
|
||||
"Audi" to mapOf(
|
||||
"516 (G4x)" to "e-tron"
|
||||
)
|
||||
)
|
||||
|
||||
fun getVehicleModel(manufacturer: String?, model: String?) =
|
||||
if (manufacturer != null && model != null) {
|
||||
models[manufacturer]?.get(model) ?: model
|
||||
} else {
|
||||
null
|
||||
}
|
||||
324
app/src/google/java/net/vonforst/evmap/auto/ChargepriceScreen.kt
Normal file
@@ -0,0 +1,324 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.banana.jsonapi2.HasMany
|
||||
import moe.banana.jsonapi2.HasOne
|
||||
import moe.banana.jsonapi2.JsonBuffer
|
||||
import moe.banana.jsonapi2.ResourceIdentifier
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.chargeprice.*
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.currency
|
||||
import java.io.IOException
|
||||
|
||||
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
|
||||
}
|
||||
private var prices: List<ChargePrice>? = null
|
||||
private var meta: ChargepriceChargepointMeta? = null
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private var errorMessage: String? = null
|
||||
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (prices == null) loadData()
|
||||
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(
|
||||
carContext.getString(
|
||||
R.string.chargeprice_battery_range,
|
||||
batteryRange[0],
|
||||
batteryRange[1]
|
||||
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
|
||||
)
|
||||
setHeaderAction(Action.BACK)
|
||||
if (prices == null && errorMessage == null) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
setNoItemsMessage(
|
||||
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
|
||||
)
|
||||
prices?.take(maxRows)?.forEach { price ->
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(formatProvider(price))
|
||||
addText(formatPrice(price))
|
||||
}.build())
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().addAction(
|
||||
Action.Builder().setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_chargeprice
|
||||
)
|
||||
).build()
|
||||
).setOnClickListener {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(
|
||||
ContextCompat.getColor(
|
||||
carContext,
|
||||
R.color.colorPrimary
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build().intent
|
||||
intent.data =
|
||||
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.no_browser_app_found,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}.build()
|
||||
).build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun formatProvider(price: ChargePrice): String {
|
||||
if (!price.tariffName.startsWith(price.provider)) {
|
||||
return price.provider + " " + price.tariffName
|
||||
} else {
|
||||
return price.tariffName
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPrice(price: ChargePrice): String {
|
||||
val totalPrice = carContext.getString(
|
||||
R.string.charge_price_format,
|
||||
price.chargepointPrices.first().price,
|
||||
currency(price.currency)
|
||||
)
|
||||
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
|
||||
carContext.getString(
|
||||
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
|
||||
R.string.charge_price_kwh_format
|
||||
} else {
|
||||
R.string.charge_price_average_format
|
||||
},
|
||||
price.chargepointPrices.get(0).price / meta!!.energy,
|
||||
currency(price.currency)
|
||||
)
|
||||
} else null
|
||||
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
|
||||
price.formatMonthlyFees(carContext)
|
||||
} else null
|
||||
var text = totalPrice
|
||||
if (kwhPrice != null && monthlyFees != null) {
|
||||
text += " ($kwhPrice, $monthlyFees)"
|
||||
} else if (kwhPrice != null) {
|
||||
text += " ($kwhPrice)"
|
||||
} else if (monthlyFees != null) {
|
||||
text += " ($monthlyFees)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
val hardwareMan =
|
||||
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
hardwareMan.carInfo.fetchModel(exec) { model ->
|
||||
loadPrices(model)
|
||||
}
|
||||
} else {
|
||||
loadPrices(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPrices(model: Model?) {
|
||||
val dataAdapter = getDataAdapter() ?: return
|
||||
val manufacturer = model?.manufacturer?.value
|
||||
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val car = determineVehicle(manufacturer, modelName)
|
||||
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
|
||||
val result = api.getChargePrices(ChargepriceRequest().apply {
|
||||
this.dataAdapter = dataAdapter
|
||||
station = cpStation
|
||||
vehicle = HasOne(car)
|
||||
tariffs = if (!prefs.chargepriceMyTariffsAll) {
|
||||
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
|
||||
HasMany<ChargepriceTariff>(*myTariffs.map {
|
||||
ResourceIdentifier(
|
||||
"tariff",
|
||||
it
|
||||
)
|
||||
}.toTypedArray()).apply {
|
||||
meta = JsonBuffer.create(
|
||||
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
|
||||
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
options = ChargepriceOptions(
|
||||
batteryRange = batteryRange.map { it.toDouble() },
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency
|
||||
)
|
||||
}, ChargepriceApi.getChargepriceLanguage())
|
||||
|
||||
val myTariffs = prefs.chargepriceMyTariffs
|
||||
|
||||
// choose the highest power chargepoint compatible with the car
|
||||
val chargepoint = cpStation.chargePoints.filterIndexed { i, cp ->
|
||||
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
|
||||
}.maxByOrNull { it.power }
|
||||
if (chargepoint == null) {
|
||||
errorMessage =
|
||||
carContext.getString(R.string.chargeprice_no_compatible_connectors)
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
meta =
|
||||
(result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp ->
|
||||
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
|
||||
}.maxByOrNull {
|
||||
it.power
|
||||
}
|
||||
|
||||
prices = result.map { cp ->
|
||||
val filteredPrices =
|
||||
cp.chargepointPrices.filter {
|
||||
it.plug == chargepoint.plug && it.power == chargepoint.power
|
||||
}
|
||||
if (filteredPrices.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
cp.clone().apply {
|
||||
chargepointPrices = filteredPrices
|
||||
}
|
||||
}
|
||||
}.filterNotNull()
|
||||
.sortedBy { it.chargepointPrices.first().price }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
myTariffs != null && it.tariff?.get()?.id in myTariffs
|
||||
}
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.chargeprice_connection_error,
|
||||
CarToast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} catch (e: NoVehicleSelectedException) {
|
||||
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
|
||||
invalidate()
|
||||
} catch (e: VehicleUnknownException) {
|
||||
errorMessage = carContext.getString(
|
||||
R.string.auto_chargeprice_vehicle_unknown,
|
||||
manufacturer,
|
||||
modelName
|
||||
)
|
||||
invalidate()
|
||||
} catch (e: VehicleAmbiguousException) {
|
||||
errorMessage = carContext.getString(
|
||||
R.string.auto_chargeprice_vehicle_ambiguous,
|
||||
manufacturer,
|
||||
modelName
|
||||
)
|
||||
invalidate()
|
||||
} catch (e: VehicleUnavailableException) {
|
||||
errorMessage =
|
||||
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NoVehicleSelectedException : Exception()
|
||||
private class VehicleUnknownException : Exception()
|
||||
private class VehicleAmbiguousException : Exception()
|
||||
private class VehicleUnavailableException : Exception()
|
||||
|
||||
private suspend fun determineVehicle(
|
||||
manufacturer: String?,
|
||||
modelName: String?
|
||||
): ChargepriceCar {
|
||||
var vehicles = api.getVehicles().filter {
|
||||
it.id in prefs.chargepriceMyVehicles
|
||||
}
|
||||
if (vehicles.isEmpty()) {
|
||||
throw NoVehicleSelectedException()
|
||||
} else if (vehicles.size > 1) {
|
||||
if (manufacturer != null) {
|
||||
vehicles = vehicles.filter {
|
||||
it.brand == manufacturer
|
||||
}
|
||||
if (vehicles.isEmpty()) {
|
||||
throw VehicleUnknownException()
|
||||
} else if (vehicles.size > 1) {
|
||||
if (modelName != null) {
|
||||
vehicles = vehicles.filter {
|
||||
it.name.startsWith(modelName)
|
||||
}
|
||||
if (vehicles.isEmpty()) {
|
||||
throw VehicleUnknownException()
|
||||
} else if (vehicles.size > 1) {
|
||||
throw VehicleAmbiguousException()
|
||||
}
|
||||
} else {
|
||||
throw VehicleAmbiguousException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw VehicleUnavailableException()
|
||||
}
|
||||
}
|
||||
return vehicles[0]
|
||||
}
|
||||
|
||||
private fun getDataAdapter(): String? = when (charger.dataSource) {
|
||||
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
|
||||
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
@@ -9,8 +10,11 @@ import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
@@ -20,21 +24,18 @@ import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.GEReferenceDataRepository
|
||||
import net.vonforst.evmap.storage.OCMReferenceDataRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.ChargerIconGenerator
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.getReferenceData
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
@@ -50,8 +51,26 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val api by lazy {
|
||||
createApi(prefs.dataSource, ctx)
|
||||
}
|
||||
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
|
||||
|
||||
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
|
||||
private val imageSize = 128 // images should be 128dp according to docs
|
||||
private val imageHeightLarge = 480 // images should be 480 x 854 dp according to docs
|
||||
private val imageWidthLarge = 854
|
||||
|
||||
private val iconGen =
|
||||
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
|
||||
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
|
||||
} else 2
|
||||
private val largeImageSupported =
|
||||
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
|
||||
|
||||
init {
|
||||
referenceData.observe(this) {
|
||||
loadCharger()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (charger == null) loadCharger()
|
||||
@@ -59,117 +78,210 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
return PaneTemplate.Builder(
|
||||
Pane.Builder().apply {
|
||||
charger?.let { charger ->
|
||||
addRow(Row.Builder().apply {
|
||||
setTitle(charger.address.toString())
|
||||
|
||||
val icon = iconGen.getBitmap(
|
||||
tint = getMarkerTint(charger),
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
if (i > 0) chargepointsText.append(" · ")
|
||||
chargepointsText.append(
|
||||
"${cp.count}× ${
|
||||
nameForPlugType(
|
||||
carContext.stringProvider(),
|
||||
cp.type
|
||||
if (largeImageSupported && photo != null) {
|
||||
setImage(CarIcon.Builder(IconCompat.createWithBitmap(photo)).build())
|
||||
}
|
||||
generateRows(charger).forEach { addRow(it) }
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
} ${cp.formatPower()}"
|
||||
).build()
|
||||
)
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
addText(chargepointsText)
|
||||
}.build())
|
||||
addRow(Row.Builder().apply {
|
||||
photo?.let {
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
val operatorText = StringBuilder().apply {
|
||||
charger.operator?.let { append(it) }
|
||||
charger.network?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(it)
|
||||
}
|
||||
}.ifEmpty {
|
||||
carContext.getString(R.string.unknown_operator)
|
||||
}
|
||||
setTitle(operatorText)
|
||||
|
||||
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
|
||||
charger.faultReport?.created?.let {
|
||||
addText(
|
||||
carContext.getString(
|
||||
R.string.auto_fault_report_date,
|
||||
it.atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/*val types = charger.chargepoints.map { it.type }.distinct()
|
||||
if (types.size == 1) {
|
||||
setImage(
|
||||
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
|
||||
Row.IMAGE_TYPE_ICON)
|
||||
}*/
|
||||
}.build())
|
||||
addAction(Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).build()
|
||||
)
|
||||
.setTitle(carContext.getString(R.string.navigate))
|
||||
.setTitle(carContext.getString(R.string.navigate))
|
||||
.setBackgroundColor(CarColor.PRIMARY)
|
||||
.setOnClickListener {
|
||||
navigateToCharger(charger)
|
||||
}
|
||||
.build())
|
||||
addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.open_in_app))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_CHARGER_ID, charger.id)
|
||||
.putExtra(EXTRA_LAT, charger.coordinates.lat)
|
||||
.putExtra(EXTRA_LON, charger.coordinates.lng)
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
})
|
||||
.build()
|
||||
)
|
||||
charger.chargepriceData?.country?.let { country ->
|
||||
if (ChargepriceApi.isCountrySupported(country, charger.dataSource)) {
|
||||
addAction(Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_chargeprice
|
||||
)
|
||||
).build()
|
||||
)
|
||||
.setTitle(carContext.getString(R.string.auto_prices))
|
||||
.setOnClickListener {
|
||||
screenManager.push(ChargepriceScreen(carContext, charger))
|
||||
}
|
||||
.build())
|
||||
}
|
||||
}
|
||||
} ?: setLoading(true)
|
||||
}.build()
|
||||
).apply {
|
||||
setTitle(chargerSparse.name)
|
||||
setHeaderAction(Action.BACK)
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.open_in_app))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
|
||||
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
|
||||
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
})
|
||||
.build()
|
||||
).build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun generateRows(charger: ChargeLocation): List<Row> {
|
||||
val rows = mutableListOf<Row>()
|
||||
|
||||
// Row 1: address + chargepoints
|
||||
rows.add(Row.Builder().apply {
|
||||
setTitle(charger.address.toString())
|
||||
|
||||
if (photo == null) {
|
||||
// show just the icon
|
||||
val icon = iconGen.getBitmap(
|
||||
tint = getMarkerTint(charger),
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
} else if (!largeImageSupported) {
|
||||
// show the photo with icon
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
addText(generateChargepointsText(charger))
|
||||
}.build())
|
||||
if (maxRows <= 3) {
|
||||
// row 2: operator + cost + fault report
|
||||
rows.add(Row.Builder().apply {
|
||||
if (photo != null && !largeImageSupported) {
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
val operatorText = generateOperatorText(charger)
|
||||
setTitle(operatorText)
|
||||
|
||||
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
|
||||
charger.faultReport?.let { fault ->
|
||||
addText(
|
||||
carContext.getString(
|
||||
R.string.auto_fault_report_date,
|
||||
fault.created?.atZone(ZoneId.systemDefault())
|
||||
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
} else {
|
||||
// row 2: operator + cost + cost description
|
||||
rows.add(Row.Builder().apply {
|
||||
if (photo != null && !largeImageSupported) {
|
||||
setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
|
||||
Row.IMAGE_TYPE_LARGE
|
||||
)
|
||||
}
|
||||
val operatorText = generateOperatorText(charger)
|
||||
setTitle(operatorText)
|
||||
charger.cost?.let {
|
||||
addText(it.getStatusText(carContext, emoji = true))
|
||||
(it.descriptionShort ?: it.descriptionLong)?.let { addText(it) }
|
||||
}
|
||||
}.build())
|
||||
// row 3: fault report (if exists)
|
||||
charger.faultReport?.let { fault ->
|
||||
rows.add(Row.Builder().apply {
|
||||
setTitle(
|
||||
carContext.getString(
|
||||
R.string.auto_fault_report_date,
|
||||
fault.created?.atZone(ZoneId.systemDefault())
|
||||
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
)
|
||||
)
|
||||
fault.description?.let {
|
||||
addText(
|
||||
HtmlCompat.fromHtml(
|
||||
it.replace("\n", " · "),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
)
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
// row 4: opening hours + location description
|
||||
charger.openinghours?.let { hours ->
|
||||
rows.add(Row.Builder().apply {
|
||||
setTitle(hours.getStatusText(carContext))
|
||||
hours.description?.let { addText(it) }
|
||||
charger.locationDescription?.let { addText(it) }
|
||||
}.build())
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
if (i > 0) chargepointsText.append(" · ")
|
||||
chargepointsText.append(
|
||||
"${cp.count}× ${
|
||||
nameForPlugType(
|
||||
carContext.stringProvider(),
|
||||
cp.type
|
||||
)
|
||||
} ${cp.formatPower()}"
|
||||
)
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return chargepointsText
|
||||
}
|
||||
|
||||
private fun generateOperatorText(charger: ChargeLocation) =
|
||||
if (charger.operator != null && charger.network != null) {
|
||||
if (charger.operator.contains(charger.network)) {
|
||||
charger.operator
|
||||
} else if (charger.network.contains(charger.operator)) {
|
||||
charger.network
|
||||
} else {
|
||||
"${charger.operator} · ${charger.network}"
|
||||
}
|
||||
} else if (charger.operator != null) {
|
||||
charger.operator
|
||||
} else if (charger.network != null) {
|
||||
charger.network
|
||||
} else {
|
||||
carContext.getString(R.string.unknown_operator)
|
||||
}
|
||||
|
||||
private fun navigateToCharger(charger: ChargeLocation) {
|
||||
val coord = charger.coordinates
|
||||
val intent =
|
||||
@@ -181,21 +293,51 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
}
|
||||
|
||||
private fun loadCharger() {
|
||||
val referenceData = referenceData.value ?: return
|
||||
lifecycleScope.launch {
|
||||
val response = api.getChargepointDetail(getReferenceData(), chargerSparse.id)
|
||||
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
|
||||
if (response.status == Status.SUCCESS) {
|
||||
charger = response.data!!
|
||||
val charger = response.data!!
|
||||
|
||||
val photo = charger?.photos?.firstOrNull()
|
||||
val photo = charger.photos?.firstOrNull()
|
||||
photo?.let {
|
||||
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
|
||||
val url = photo.getUrl(size = size)
|
||||
val density = carContext.resources.displayMetrics.density
|
||||
val url = if (largeImageSupported) {
|
||||
photo.getUrl(
|
||||
width = (imageWidthLarge * density).roundToInt(),
|
||||
height = (imageHeightLarge * density).roundToInt()
|
||||
)
|
||||
} else {
|
||||
photo.getUrl(size = (imageSize * density).roundToInt())
|
||||
}
|
||||
val request = ImageRequest.Builder(carContext).data(url).build()
|
||||
this@ChargerDetailScreen.photo =
|
||||
var img =
|
||||
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
|
||||
}
|
||||
|
||||
availability = charger?.let { getAvailability(it).data }
|
||||
// draw icon on top of image
|
||||
val icon = iconGen.getBitmap(
|
||||
tint = getMarkerTint(charger),
|
||||
fault = charger.faultReport != null,
|
||||
multi = charger.isMulti()
|
||||
)
|
||||
|
||||
img = img.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val iconSmall = icon.scale(
|
||||
(img.height * 0.4 / icon.height * icon.width).roundToInt(),
|
||||
(img.height * 0.4).roundToInt()
|
||||
)
|
||||
val canvas = Canvas(img)
|
||||
canvas.drawBitmap(
|
||||
iconSmall,
|
||||
0f,
|
||||
(img.height - iconSmall.height * 1.1).toFloat(),
|
||||
null
|
||||
)
|
||||
this@ChargerDetailScreen.photo = img
|
||||
}
|
||||
this@ChargerDetailScreen.charger = charger
|
||||
|
||||
availability = getAvailability(charger).data
|
||||
|
||||
invalidate()
|
||||
} else {
|
||||
@@ -206,29 +348,4 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getReferenceData(): ReferenceData {
|
||||
val api = api
|
||||
return when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.FilterProfile
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FilterScreen(ctx: CarContext) : Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(ctx)
|
||||
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
db.filterProfileDao().getProfiles(prefs.dataSource)
|
||||
}
|
||||
private val maxRows = 6
|
||||
private val checkIcon =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
|
||||
private val emptyIcon: CarIcon
|
||||
|
||||
init {
|
||||
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
|
||||
emptyIcon = Bitmap.createBitmap(
|
||||
size,
|
||||
size,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
}
|
||||
|
||||
init {
|
||||
filterProfiles.observe(this) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val filterStatus =
|
||||
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
return ListTemplate.Builder().apply {
|
||||
filterProfiles.value?.let {
|
||||
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
|
||||
} ?: setLoading(true)
|
||||
setTitle(carContext.getString(R.string.menu_filter))
|
||||
setHeaderAction(Action.BACK)
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun buildFilterProfilesList(
|
||||
profiles: List<FilterProfile>,
|
||||
filterStatus: Long
|
||||
): ItemList {
|
||||
return ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.no_filters))
|
||||
if (FILTERS_DISABLED == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = FILTERS_DISABLED
|
||||
screenManager.pop()
|
||||
}
|
||||
}.build())
|
||||
profiles.forEach {
|
||||
addItem(Row.Builder().apply {
|
||||
val name =
|
||||
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
|
||||
setTitle(name)
|
||||
if (it.id == filterStatus) {
|
||||
setImage(checkIcon)
|
||||
} else {
|
||||
setImage(emptyIcon)
|
||||
}
|
||||
setOnClickListener {
|
||||
prefs.filterStatus = it.id
|
||||
screenManager.pop()
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.car2go.maps.model.LatLng
|
||||
import kotlinx.coroutines.*
|
||||
@@ -15,46 +23,76 @@ import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.getAvailability
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.await
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.model.FILTERS_CUSTOM
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.model.FILTERS_FAVORITES
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.GEReferenceDataRepository
|
||||
import net.vonforst.evmap.storage.OCMReferenceDataRepository
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.availabilityText
|
||||
import net.vonforst.evmap.ui.getMarkerTint
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
import net.vonforst.evmap.viewmodel.filtersWithValue
|
||||
import net.vonforst.evmap.viewmodel.getFilterValues
|
||||
import net.vonforst.evmap.viewmodel.getFilters
|
||||
import net.vonforst.evmap.viewmodel.getReferenceData
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Main map screen showing either nearby chargers or favorites
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
|
||||
Screen(ctx), LocationAwareScreen {
|
||||
private var updateCoroutine: Job? = null
|
||||
private var numUpdates = 0
|
||||
private val maxNumUpdates = 3
|
||||
|
||||
/* Updating map contents is disabled - if the user uses Chargeprice from the charger
|
||||
detail screen, this already means 4 steps, after which the app would crash.
|
||||
follow https://issuetracker.google.com/issues/176694222 for updates how to solve this. */
|
||||
private val maxNumUpdates = 1
|
||||
|
||||
private var location: Location? = null
|
||||
private var lastUpdateLocation: Location? = null
|
||||
private var lastChargerUpdateLocation: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private var prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val api by lazy {
|
||||
createApi(prefs.dataSource, ctx)
|
||||
}
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val updateThreshold = 2000 // meters
|
||||
private val chargerUpdateThreshold = 2000 // meters
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
|
||||
HashMap()
|
||||
private val maxRows = 6
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
|
||||
} else 6
|
||||
|
||||
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
|
||||
private val filterStatus = MutableLiveData<Long>().apply {
|
||||
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
}
|
||||
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
|
||||
private val filters = api.getFilters(referenceData, carContext.stringProvider())
|
||||
private val filtersWithValue = filtersWithValue(filters, filterValues)
|
||||
|
||||
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
|
||||
init {
|
||||
filtersWithValue.observe(this) {
|
||||
loadChargers()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
session.mapScreen = this
|
||||
@@ -91,12 +129,49 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
} ?: setLoading(true)
|
||||
setCurrentLocationEnabled(true)
|
||||
setHeaderAction(Action.BACK)
|
||||
if (!favorites) {
|
||||
val filtersCount = filtersWithValue.value?.count {
|
||||
!it.value.hasSameValueAs(it.filter.defaultValue())
|
||||
}
|
||||
|
||||
setActionStrip(
|
||||
ActionStrip.Builder()
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setIcon(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_filter
|
||||
)
|
||||
)
|
||||
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.pushForResult(FilterScreen(carContext)) {
|
||||
chargers = null
|
||||
numUpdates = 0
|
||||
filterStatus.value =
|
||||
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
|
||||
?: FILTERS_DISABLED
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
}
|
||||
build()
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
|
||||
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
|
||||
val markerTint = if (charger.maxPower > 100) {
|
||||
R.color.charger_100kw_dark // slightly darker color for better contrast
|
||||
} else {
|
||||
getMarkerTint(charger)
|
||||
}
|
||||
val color = ContextCompat.getColor(carContext, markerTint)
|
||||
val place =
|
||||
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
|
||||
.setMarker(
|
||||
@@ -122,13 +197,18 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
|
||||
// distance
|
||||
location?.let {
|
||||
val distance = distanceBetween(
|
||||
val distanceMeters = distanceBetween(
|
||||
it.latitude, it.longitude,
|
||||
charger.coordinates.lat, charger.coordinates.lng
|
||||
) / 1000
|
||||
)
|
||||
text.append(
|
||||
"distance",
|
||||
DistanceSpan.create(Distance.create(distance, Distance.UNIT_KILOMETERS)),
|
||||
DistanceSpan.create(
|
||||
roundValueToDistance(
|
||||
distanceMeters,
|
||||
energyLevel?.distanceDisplayUnit?.value
|
||||
)
|
||||
),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
@@ -165,31 +245,45 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
if (location.latitude == this.location?.latitude
|
||||
&& location.longitude == this.location?.longitude
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.location = location
|
||||
if (updateCoroutine != null) {
|
||||
// don't update while still loading last update
|
||||
return
|
||||
}
|
||||
|
||||
invalidate()
|
||||
|
||||
if (lastUpdateLocation == null ||
|
||||
location.distanceTo(lastUpdateLocation) > updateThreshold
|
||||
val now = Instant.now()
|
||||
if (lastDistanceUpdateTime == null ||
|
||||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
|
||||
) {
|
||||
lastUpdateLocation = location
|
||||
lastDistanceUpdateTime = now
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
if (lastChargerUpdateLocation == null ||
|
||||
location.distanceTo(lastChargerUpdateLocation) > chargerUpdateThreshold
|
||||
) {
|
||||
lastChargerUpdateLocation = location
|
||||
// update displayed chargers
|
||||
loadChargers(location)
|
||||
loadChargers()
|
||||
}
|
||||
}
|
||||
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private fun loadChargers() {
|
||||
val location = location ?: return
|
||||
val referenceData = referenceData.value ?: return
|
||||
val filters = filtersWithValue.value ?: return
|
||||
|
||||
private fun loadChargers(location: Location) {
|
||||
numUpdates++
|
||||
println(numUpdates)
|
||||
if (numUpdates > maxNumUpdates) {
|
||||
CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
|
||||
.show()
|
||||
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
|
||||
.show()*/
|
||||
return
|
||||
}
|
||||
updateCoroutine = lifecycleScope.launch {
|
||||
@@ -204,22 +298,22 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
} else {
|
||||
val response = api.getChargepointsRadius(
|
||||
getReferenceData(),
|
||||
referenceData,
|
||||
LatLng.fromLocation(location),
|
||||
searchRadius,
|
||||
zoom = 16f,
|
||||
null
|
||||
filters
|
||||
)
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
chargers?.let {
|
||||
if (it.size < 6) {
|
||||
if (it.size < maxRows) {
|
||||
// try again with larger radius
|
||||
val response = api.getChargepointsRadius(
|
||||
getReferenceData(),
|
||||
referenceData,
|
||||
LatLng.fromLocation(location),
|
||||
searchRadius * 5,
|
||||
searchRadius * 10,
|
||||
zoom = 16f,
|
||||
emptyList()
|
||||
filters
|
||||
)
|
||||
chargers =
|
||||
response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
@@ -250,6 +344,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}?.awaitAll()
|
||||
|
||||
updateCoroutine = null
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -260,28 +355,29 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getReferenceData(): ReferenceData {
|
||||
val api = api
|
||||
return when (api) {
|
||||
is GoingElectricApiWrapper -> {
|
||||
GEReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.geReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
is OpenChargeMapApiWrapper -> {
|
||||
OCMReferenceDataRepository(
|
||||
api,
|
||||
lifecycleScope,
|
||||
db.ocmReferenceDataDao(),
|
||||
prefs
|
||||
).getReferenceData().await()
|
||||
}
|
||||
else -> {
|
||||
throw RuntimeException("no reference data implemented")
|
||||
}
|
||||
}
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
this.energyLevel = energyLevel
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun setupListeners() {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
"com.google.android.gms.permission.CAR_FUEL"
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
return
|
||||
|
||||
println("Setting up energy level listener")
|
||||
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
private fun removeListeners() {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
|
||||
class PermissionActivity : Activity() {
|
||||
companion object {
|
||||
const val EXTRA_RESULT_RECEIVER = "result_receiver";
|
||||
const val RESULT_GRANTED = "granted"
|
||||
}
|
||||
|
||||
private lateinit var resultReceiver: ResultReceiver
|
||||
private val permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private val requestCode = 1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent != null) {
|
||||
resultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)!!
|
||||
if (!hasPermissions(permissions)) {
|
||||
ActivityCompat.requestPermissions(this, permissions, requestCode)
|
||||
} else {
|
||||
onComplete(
|
||||
requestCode,
|
||||
permissions,
|
||||
intArrayOf(PackageManager.PERMISSION_GRANTED)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onComplete(requestCode: Int, permissions: Array<String>?, grantResults: IntArray) {
|
||||
val bundle = Bundle()
|
||||
bundle.putBoolean(
|
||||
RESULT_GRANTED,
|
||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED })
|
||||
resultReceiver.send(requestCode, bundle)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun hasPermissions(permissions: Array<String>): Boolean {
|
||||
var result = true
|
||||
for (permission in permissions) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
permission
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
onComplete(requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
import net.vonforst.evmap.R
|
||||
|
||||
/**
|
||||
* Screen to grant location permission
|
||||
* Screen to grant permission
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
class PermissionScreen(
|
||||
ctx: CarContext,
|
||||
@StringRes val message: Int,
|
||||
val permissions: List<String>
|
||||
) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
|
||||
return MessageTemplate.Builder(carContext.getString(message))
|
||||
.setTitle(carContext.getString(R.string.app_name))
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
.addAction(
|
||||
@@ -23,32 +23,7 @@ class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx)
|
||||
.setTitle(carContext.getString(R.string.grant_on_phone))
|
||||
.setBackgroundColor(CarColor.PRIMARY)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
val intent = Intent(carContext, PermissionActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(
|
||||
PermissionActivity.EXTRA_RESULT_RECEIVER,
|
||||
object : ResultReceiver(null) {
|
||||
override fun onReceiveResult(
|
||||
resultCode: Int,
|
||||
resultData: Bundle?
|
||||
) {
|
||||
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
|
||||
session.bindLocationService()
|
||||
screenManager.push(
|
||||
WelcomeScreen(
|
||||
carContext,
|
||||
session
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
requestPermissions()
|
||||
})
|
||||
.build()
|
||||
)
|
||||
@@ -62,4 +37,14 @@ class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
carContext.requestPermissions(permissions) { granted, rejected ->
|
||||
if (granted.containsAll(permissions)) {
|
||||
screenManager.pop()
|
||||
} else {
|
||||
requestPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
|
||||
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
|
||||
@@ -17,4 +28,122 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
|
||||
} else {
|
||||
CarColor.BLUE
|
||||
}
|
||||
}
|
||||
|
||||
val CarContext.constraintManager
|
||||
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
|
||||
|
||||
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
|
||||
|
||||
private const val kmPerMile = 1.609344
|
||||
private const val ftPerMile = 5280
|
||||
private const val ydPerMile = 1760
|
||||
|
||||
fun getDefaultDistanceUnit(): Int {
|
||||
return if (usesImperialUnits(Locale.getDefault())) {
|
||||
CarUnit.MILE
|
||||
} else {
|
||||
CarUnit.KILOMETER
|
||||
}
|
||||
}
|
||||
|
||||
fun usesImperialUnits(locale: Locale): Boolean {
|
||||
return locale.country in listOf("US", "GB", "MM", "LR")
|
||||
|| locale.country == "" && locale.language == "en"
|
||||
}
|
||||
|
||||
fun getDefaultSpeedUnit(): Int {
|
||||
return when (Locale.getDefault().country) {
|
||||
"US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
|
||||
else -> CarUnit.KILOMETERS_PER_HOUR
|
||||
}
|
||||
}
|
||||
|
||||
fun formatCarUnitDistance(value: Float?, unit: Int?): String {
|
||||
if (value == null) return ""
|
||||
return when (unit ?: getDefaultDistanceUnit()) {
|
||||
// distance units: base unit is meters
|
||||
CarUnit.METER -> "%.0f m".format(value)
|
||||
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
|
||||
CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that...
|
||||
CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
|
||||
if (value == null) return ""
|
||||
return when (unit ?: getDefaultSpeedUnit()) {
|
||||
// speed units: base unit is meters per second
|
||||
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
|
||||
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
|
||||
CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
|
||||
// value is in meters
|
||||
when (unit ?: getDefaultDistanceUnit()) {
|
||||
CarUnit.MILE -> {
|
||||
// imperial system
|
||||
val miles = value / 1000 / kmPerMile
|
||||
val yards = miles * ydPerMile
|
||||
val feet = miles * ftPerMile
|
||||
|
||||
return when (miles) {
|
||||
in 0.0..0.1 -> if (Locale.getDefault().country == "UK") {
|
||||
Distance.create(roundToMultipleOf(yards, 10.0), Distance.UNIT_YARDS)
|
||||
} else {
|
||||
Distance.create(roundToMultipleOf(feet, 10.0), Distance.UNIT_FEET)
|
||||
}
|
||||
in 0.1..10.0 -> Distance.create(
|
||||
roundToMultipleOf(miles, 0.1),
|
||||
Distance.UNIT_MILES_P1
|
||||
)
|
||||
else -> Distance.create(roundToMultipleOf(miles, 1.0), Distance.UNIT_MILES)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// metric system
|
||||
return when (value) {
|
||||
in 0.0..999.0 -> Distance.create(
|
||||
roundToMultipleOf(value, 10.0),
|
||||
Distance.UNIT_METERS
|
||||
)
|
||||
in 1000.0..10000.0 -> Distance.create(
|
||||
roundToMultipleOf(value / 1000, 0.1),
|
||||
Distance.UNIT_KILOMETERS_P1
|
||||
)
|
||||
else -> Distance.create(
|
||||
roundToMultipleOf(value / 1000, 1.0),
|
||||
Distance.UNIT_KILOMETERS
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun roundToMultipleOf(num: Double, step: Double): Double {
|
||||
return (num / step).roundToInt() * step
|
||||
}
|
||||
|
||||
fun getAndroidAutoVersion(ctx: Context): List<String> {
|
||||
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
|
||||
return info.versionName.split(".")
|
||||
}
|
||||
|
||||
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||
if (ctx.carAppApiLevel < CarAppApiLevels.LEVEL_3) return false
|
||||
ctx.hostInfo?.let { hostInfo ->
|
||||
if (hostInfo.packageName == "com.google.android.projection.gearhead") {
|
||||
val version = getAndroidAutoVersion(ctx)
|
||||
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
|
||||
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
|
||||
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
220
app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.info.EnergyLevel
|
||||
import androidx.car.app.hardware.info.Model
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.Gauge
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
|
||||
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
|
||||
private var model: Model? = null
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
private var speed: Speed? = null
|
||||
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
|
||||
private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
|
||||
|
||||
private val permissions = listOf(
|
||||
"com.google.android.gms.permission.CAR_FUEL",
|
||||
"com.google.android.gms.permission.CAR_SPEED"
|
||||
)
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (!permissionsGranted()) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
screenManager.pushForResult(
|
||||
PermissionScreen(
|
||||
carContext,
|
||||
R.string.auto_vehicle_data_permission_needed,
|
||||
permissions
|
||||
)
|
||||
) {
|
||||
setupListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val energyLevel = energyLevel
|
||||
val model = model
|
||||
val speed = speed
|
||||
|
||||
return GridTemplate.Builder().apply {
|
||||
setTitle(
|
||||
if (model != null && model.manufacturer.value != null && model.name.value != null) {
|
||||
"${model.manufacturer.value} ${
|
||||
getVehicleModel(
|
||||
model.manufacturer.value,
|
||||
model.name.value
|
||||
)
|
||||
}"
|
||||
} else {
|
||||
carContext.getString(R.string.auto_vehicle_data)
|
||||
}
|
||||
)
|
||||
setHeaderAction(Action.BACK)
|
||||
if (!permissionsGranted()) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setSingleList(
|
||||
ItemList.Builder().apply {
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.auto_charging_level))
|
||||
if (energyLevel == null) {
|
||||
setLoading(true)
|
||||
} else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) {
|
||||
// both battery and fuel (Plug-in hybrid)
|
||||
setText(
|
||||
"\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format(
|
||||
energyLevel.batteryPercent.value,
|
||||
energyLevel.fuelPercent.value
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
gauge.draw(
|
||||
energyLevel.batteryPercent.value,
|
||||
energyLevel.fuelPercent.value
|
||||
).asCarIcon()
|
||||
)
|
||||
} else if (energyLevel.batteryPercent.value != null) {
|
||||
// BEV
|
||||
setText("%.0f %%".format(energyLevel.batteryPercent.value))
|
||||
setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon())
|
||||
} else if (energyLevel.fuelPercent.value != null) {
|
||||
// ICE
|
||||
setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value))
|
||||
setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon())
|
||||
} else {
|
||||
setText(carContext.getString(R.string.auto_no_data))
|
||||
setImage(gauge.draw(0f).asCarIcon())
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.auto_range))
|
||||
if (energyLevel == null) {
|
||||
setLoading(true)
|
||||
} else if (energyLevel.rangeRemainingMeters.value != null) {
|
||||
setText(
|
||||
formatCarUnitDistance(
|
||||
energyLevel.rangeRemainingMeters.value,
|
||||
energyLevel.distanceDisplayUnit.value
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_car
|
||||
)
|
||||
).build()
|
||||
)
|
||||
} else {
|
||||
setText(carContext.getString(R.string.auto_no_data))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_car
|
||||
)
|
||||
).build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.auto_speed))
|
||||
if (speed == null) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
val rawSpeed = speed.rawSpeedMetersPerSecond.value
|
||||
val displaySpeed = speed.displaySpeedMetersPerSecond.value
|
||||
if (rawSpeed != null) {
|
||||
setText(
|
||||
formatCarUnitSpeed(
|
||||
rawSpeed,
|
||||
speed.speedDisplayUnit.value
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon()
|
||||
)
|
||||
} else if (displaySpeed != null) {
|
||||
setText(
|
||||
formatCarUnitSpeed(
|
||||
speed.displaySpeedMetersPerSecond.value,
|
||||
speed.speedDisplayUnit.value
|
||||
)
|
||||
)
|
||||
setImage(
|
||||
gauge.draw(min(displaySpeed / maxSpeed * 100, 100f))
|
||||
.asCarIcon()
|
||||
)
|
||||
} else {
|
||||
setText(carContext.getString(R.string.auto_no_data))
|
||||
setImage(gauge.draw(0f).asCarIcon())
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
this.energyLevel = energyLevel
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun onSpeedUpdated(speed: Speed) {
|
||||
this.speed = speed
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
private fun setupListeners() {
|
||||
if (!permissionsGranted()) return
|
||||
|
||||
println("Setting up energy level listener")
|
||||
|
||||
val exec = ContextCompat.getMainExecutor(carContext)
|
||||
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
|
||||
|
||||
hardwareMan.carInfo.fetchModel(exec) {
|
||||
this.model = it
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
private fun removeListeners() {
|
||||
println("Removing energy level listener")
|
||||
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
|
||||
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
|
||||
}
|
||||
|
||||
private fun permissionsGranted(): Boolean =
|
||||
permissions.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
carContext,
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.Manifest
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.*
|
||||
@@ -10,60 +13,108 @@ import net.vonforst.evmap.R
|
||||
/**
|
||||
* Welcome screen with selection between favorites and nearby chargers
|
||||
*/
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
|
||||
private var location: Location? = null
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
if (!session.locationPermissionGranted()) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
screenManager.pushForResult(
|
||||
PermissionScreen(
|
||||
carContext,
|
||||
R.string.auto_location_permission_needed,
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
) {
|
||||
session.bindLocationService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.mapScreen = this
|
||||
return PlaceListMapTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.app_name))
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it)).build())
|
||||
}
|
||||
setItemList(ItemList.Builder().apply {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_address
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(MapScreen(carContext, session, favorites = false))
|
||||
}
|
||||
.build())
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_favorites))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_fav
|
||||
if (!session.locationPermissionGranted()) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
location?.let {
|
||||
setAnchor(Place.Builder(CarLocation.create(it)).build())
|
||||
}
|
||||
setItemList(ItemList.Builder().apply {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_address
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(
|
||||
MapScreen(
|
||||
carContext,
|
||||
session,
|
||||
favorites = false
|
||||
)
|
||||
)
|
||||
}
|
||||
.build())
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_favorites))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_fav
|
||||
)
|
||||
)
|
||||
.setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(MapScreen(carContext, session, favorites = true))
|
||||
}
|
||||
.build())
|
||||
if (supportsCarApiLevel3(carContext)) {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_vehicle_data))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(carContext, R.drawable.ic_car)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
session.mapScreen = null
|
||||
screenManager.push(VehicleDataScreen(carContext))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(MapScreen(carContext, session, favorites = true))
|
||||
}
|
||||
.build())
|
||||
}.build())
|
||||
setCurrentLocationEnabled(true)
|
||||
}
|
||||
}.build())
|
||||
setCurrentLocationEnabled(true)
|
||||
}
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
build()
|
||||
}.build()
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
if (location.latitude == this.location?.latitude
|
||||
&& location.longitude == this.location?.longitude
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.location = location
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.car2go.maps.google.adapter.AnyMapAdapter
|
||||
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 net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE
|
||||
import net.vonforst.evmap.viewmodel.PlaceWithBounds
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
fun launchAutocomplete(fragment: Fragment) {
|
||||
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
|
||||
val intent: Intent = Autocomplete.IntentBuilder(
|
||||
AutocompleteActivityMode.OVERLAY, fields
|
||||
)
|
||||
.build(fragment.requireActivity())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
|
||||
|
||||
// show keyboard
|
||||
val imm = fragment.requireContext()
|
||||
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.toggleSoftInput(0, 0)
|
||||
}
|
||||
|
||||
fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? {
|
||||
val place = Autocomplete.getPlaceFromIntent(intent)
|
||||
return PlaceWithBounds(AnyMapAdapter.adapt(place.latLng), AnyMapAdapter.adapt(place.viewport))
|
||||
}
|
||||
fun getAutocompleteProviders(context: Context) =
|
||||
if (PreferenceDataSource(context).searchProvider == "google") {
|
||||
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
|
||||
} else {
|
||||
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.StyleSpan
|
||||
import com.car2go.maps.google.adapter.AnyMapAdapter
|
||||
import com.car2go.maps.util.SphericalUtil
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.android.gms.tasks.Tasks.await
|
||||
import com.google.android.libraries.places.api.Places
|
||||
import com.google.android.libraries.places.api.model.AutocompleteSessionToken
|
||||
import com.google.android.libraries.places.api.model.Place
|
||||
import com.google.android.libraries.places.api.model.RectangularBounds
|
||||
import com.google.android.libraries.places.api.net.FetchPlaceRequest
|
||||
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
|
||||
import com.google.android.libraries.places.api.net.PlacesStatusCodes
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import net.vonforst.evmap.R
|
||||
import java.util.concurrent.ExecutionException
|
||||
import kotlin.math.sqrt
|
||||
|
||||
|
||||
class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
private var token = AutocompleteSessionToken.newInstance()
|
||||
private val client = Places.createClient(context)
|
||||
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
|
||||
|
||||
override val id = "google"
|
||||
|
||||
override fun autocomplete(
|
||||
query: String,
|
||||
location: com.car2go.maps.model.LatLng?
|
||||
): List<AutocompletePlace> {
|
||||
val request = FindAutocompletePredictionsRequest.builder().apply {
|
||||
if (location != null) {
|
||||
setLocationBias(calcLocationBias(location))
|
||||
setOrigin(LatLng(location.latitude, location.longitude))
|
||||
}
|
||||
setSessionToken(token)
|
||||
setQuery(query)
|
||||
}.build()
|
||||
try {
|
||||
val result =
|
||||
await(client.findAutocompletePredictions(request)).autocompletePredictions
|
||||
return result.map {
|
||||
AutocompletePlace(
|
||||
it.getPrimaryText(bold),
|
||||
it.getSecondaryText(bold),
|
||||
it.placeId,
|
||||
it.distanceMeters?.toDouble(),
|
||||
it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) })
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
val cause = e.cause
|
||||
if (cause is ApiException) {
|
||||
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
|
||||
throw ApiUnavailableException()
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(id: String): PlaceWithBounds {
|
||||
val request =
|
||||
FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build()
|
||||
try {
|
||||
val place = client.fetchPlace(request).await().place
|
||||
token = AutocompleteSessionToken.newInstance()
|
||||
return PlaceWithBounds(
|
||||
AnyMapAdapter.adapt(place.latLng),
|
||||
AnyMapAdapter.adapt(place.viewport)
|
||||
)
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
|
||||
throw ApiUnavailableException()
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAttributionString(): Int = R.string.places_powered_by_google
|
||||
|
||||
override fun getAttributionImage(dark: Boolean): Int =
|
||||
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
|
||||
|
||||
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
|
||||
val radius = 100e3 // meters
|
||||
val northEast =
|
||||
SphericalUtil.computeOffset(
|
||||
location,
|
||||
radius * sqrt(2.0),
|
||||
45.0
|
||||
)
|
||||
val southWest =
|
||||
SphericalUtil.computeOffset(
|
||||
location,
|
||||
radius * sqrt(2.0),
|
||||
225.0
|
||||
)
|
||||
return RectangularBounds.newInstance(
|
||||
LatLngBounds(
|
||||
AnyMapAdapter.adapt(southWest),
|
||||
AnyMapAdapter.adapt(northEast)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -13,7 +12,9 @@ import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.DonationAdapter
|
||||
@@ -24,24 +25,29 @@ class DonateFragment : Fragment() {
|
||||
private lateinit var binding: FragmentDonateBinding
|
||||
private val vm: DonateViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): 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)
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
binding.toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
@@ -54,11 +60,18 @@ class DonateFragment : Fragment() {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
|
||||
vm.products.observe(viewLifecycleOwner) {
|
||||
print(it)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
|
||||
|
||||
class OnboardingViewPagerAdapter(fragment: Fragment) :
|
||||
FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 4
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> WelcomeFragment()
|
||||
1 -> IconsFragment()
|
||||
2 -> AndroidAutoFragment()
|
||||
3 -> DataSourceSelectFragment()
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidAutoFragment : OnboardingPageFragment() {
|
||||
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
|
||||
|
||||
binding.btnGetStarted.setOnClickListener {
|
||||
parent.goToNext()
|
||||
}
|
||||
binding.imgAndroidAuto.alpha = 0f
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val animators =
|
||||
listOf(
|
||||
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
},
|
||||
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
}
|
||||
)
|
||||
AnimatorSet().apply {
|
||||
playTogether(animators)
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.imgAndroidAuto.alpha = 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.RangeSliderPreference
|
||||
import net.vonforst.evmap.viewmodel.SettingsViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import java.text.NumberFormat
|
||||
|
||||
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
|
||||
override val isTopLevel = false
|
||||
|
||||
private lateinit var rangePreference: RangeSliderPreference
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
rangePreference = findPreference("chargeprice_battery_range_android_auto")!!
|
||||
rangePreference.labelFormatter = { value: Float ->
|
||||
val fmt = NumberFormat.getNumberInstance()
|
||||
fmt.maximumFractionDigits = 0
|
||||
fmt.format(value.toDouble()) + "%"
|
||||
}
|
||||
updateRangePreferenceSummary()
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
|
||||
updateRangePreferenceSummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRangePreferenceSummary() {
|
||||
val range = prefs.chargepriceBatteryRangeAndroidAuto
|
||||
rangePreference.summary = getString(R.string.chargeprice_battery_range, range[0], range[1])
|
||||
}
|
||||
}
|
||||
74
app/src/google/java/net/vonforst/evmap/ui/Gauge.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package net.vonforst.evmap.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import net.vonforst.evmap.R
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class Gauge(val size: Int, ctx: Context) {
|
||||
val arcPaint = Paint().apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = size * 0.15f
|
||||
}
|
||||
val gaugePaint = Paint()
|
||||
val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active)
|
||||
val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle)
|
||||
val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive)
|
||||
|
||||
fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||
|
||||
val angle = valuePercent?.let { 180f * it / 100 } ?: 0f
|
||||
val secondAngle = secondValuePercent?.let { 180f * it / 100 }
|
||||
|
||||
drawArc(angle, secondAngle, canvas)
|
||||
if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas)
|
||||
drawGauge(angle, Color.WHITE, canvas)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) {
|
||||
gaugePaint.color = color
|
||||
canvas.save()
|
||||
canvas.rotate(angle - 90, size / 2f, 3 * size / 4f)
|
||||
canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint)
|
||||
canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) {
|
||||
val (angle1, angle2) = if (secondAngle != null) {
|
||||
min(angle, secondAngle) to max(angle, secondAngle)
|
||||
} else {
|
||||
angle to null
|
||||
}
|
||||
|
||||
arcPaint.color = activeColor
|
||||
val arcBounds = RectF(
|
||||
arcPaint.strokeWidth / 2,
|
||||
size / 4f + arcPaint.strokeWidth / 2,
|
||||
size - arcPaint.strokeWidth / 2,
|
||||
5 * size / 4f - arcPaint.strokeWidth / 2
|
||||
)
|
||||
|
||||
canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint)
|
||||
if (angle2 != null) {
|
||||
arcPaint.color = middleColor
|
||||
canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint)
|
||||
}
|
||||
arcPaint.color = inactiveColor
|
||||
canvas.drawArc(
|
||||
arcBounds,
|
||||
180f + (angle2 ?: angle1),
|
||||
180f - (angle2 ?: angle1),
|
||||
false,
|
||||
arcPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
.build()
|
||||
billingClient.querySkuDetailsAsync(params) { result, details ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
|
||||
products.value = Resource.success(details
|
||||
products.postValue(Resource.success(details
|
||||
.sortedBy { it.priceAmountMicros }
|
||||
.map { DonationItem(it) }
|
||||
)
|
||||
))
|
||||
} else {
|
||||
products.value = Resource.error(result.debugMessage, null)
|
||||
products.postValue(Resource.error(result.debugMessage, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
app/src/google/res/drawable/ic_android_auto.xml
Normal 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="#FF000000"
|
||||
android:pathData="m22.78,17.91c0.16,0.25 0.22,0.51 0.22,0.79 0,0.38 -0.13,0.69 -0.43,0.94s-0.63,0.36 -1.01,0.36h-2.48l-6.66,-12h-0.84l-6.66,12h-2.53c-0.47,0 -0.86,-0.2 -1.17,-0.62s-0.33,-0.88 -0.05,-1.38l9.61,-16.31c0.31,-0.47 0.72,-0.69 1.22,-0.69 0.53,0 0.92,0.22 1.17,0.69zM4.78,22.31 L12,9.38 19.22,22.31 18.5,23 12,20.34 5.44,23z" />
|
||||
</vector>
|
||||
10
app/src/google/res/drawable/ic_car.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="72dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/welcome_android_auto"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/img_android_auto" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
android:breakStrategy="balanced"
|
||||
android:text="@string/welcome_android_auto_detail"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/welcomeTitle" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGetStarted"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/sounds_cool"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/welcomeText" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_android_auto"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginStart="72dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/circle_bg_logo"
|
||||
android:backgroundTint="@color/android_auto_accent"
|
||||
android:scaleType="center"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.65"
|
||||
app:srcCompat="@drawable/android_auto" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/welcome_android_auto"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
android:breakStrategy="balanced"
|
||||
android:gravity="center"
|
||||
android:text="@string/welcome_android_auto_detail"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGetStarted"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/sounds_cool"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_android_auto"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/circle_bg_logo"
|
||||
android:backgroundTint="@color/android_auto_accent"
|
||||
android:scaleType="center"
|
||||
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.7"
|
||||
app:srcCompat="@drawable/android_auto" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -51,7 +51,7 @@
|
||||
android:id="@+id/products_list"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:data="@{vm.products.data}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/welcome_android_auto"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
android:breakStrategy="balanced"
|
||||
android:gravity="center"
|
||||
android:text="@string/welcome_android_auto_detail"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGetStarted"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/sounds_cool"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_android_auto"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/circle_bg_logo"
|
||||
android:backgroundTint="@color/android_auto_accent"
|
||||
android:scaleType="center"
|
||||
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.7"
|
||||
app:srcCompat="@drawable/android_auto" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -25,23 +25,24 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView15"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@{item.sku.title}"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
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" />
|
||||
tools:text="Spende (extrem langer Beschreibungstext)" />
|
||||
|
||||
<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:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
||||
@@ -4,16 +4,34 @@
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<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-array name="pref_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<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 15% Gebühren ab.</string>
|
||||
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
|
||||
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
|
||||
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
|
||||
<string name="open_in_app">In App öffnen</string>
|
||||
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
|
||||
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
|
||||
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
|
||||
<string name="grant_on_phone">Auf Telefon zulassen</string>
|
||||
<string name="auto_chargers_closeby">In der Nähe</string>
|
||||
<string name="auto_favorites">Favoriten</string>
|
||||
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
|
||||
<string name="auto_prices">Preise</string>
|
||||
<string name="auto_vehicle_data">Fahrzeugdaten</string>
|
||||
<string name="auto_charging_level">Ladezustand</string>
|
||||
<string name="auto_no_data">Nicht verfügbar</string>
|
||||
<string name="auto_range">Reichweite</string>
|
||||
<string name="auto_speed">Geschwindigkeit</string>
|
||||
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
|
||||
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
|
||||
<string name="sounds_cool">klingt cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
|
||||
</resources>
|
||||
7
app/src/google/res/values/colors.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="gauge_active">#00e676</color>
|
||||
<color name="gauge_middle">#087f23</color>
|
||||
<color name="gauge_inactive">#9e9e9e</color>
|
||||
<color name="charger_100kw_dark">#fdd835</color>
|
||||
</resources>
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
<style name="CarAppTheme">
|
||||
<item name="carColorPrimary">@color/colorPrimary</item>
|
||||
<item name="carColorPrimaryDark">@color/colorPrimaryVariant</item>
|
||||
<item name="carColorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="carColorSecondary">@color/colorSecondary</item>
|
||||
<item name="carColorSecondaryDark">@color/colorSecondaryVariant</item>
|
||||
<item name="carColorSecondaryDark">@color/colorSecondaryDark</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -8,17 +8,40 @@
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_map_provider_default" translatable="false">google</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="pref_search_provider_default" translatable="false">mapbox</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 15% off every donation.</string>
|
||||
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
|
||||
<string name="auto_no_chargers_found">No nearby chargers found</string>
|
||||
<string name="auto_no_favorites_found">No favorites found</string>
|
||||
<string name="open_in_app">Open in app</string>
|
||||
<string name="opened_on_phone">Opened on phone</string>
|
||||
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
|
||||
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
|
||||
<string name="grant_on_phone">Grant on phone</string>
|
||||
<string name="auto_chargers_closeby">Nearby chargers</string>
|
||||
<string name="auto_favorites">Favorites</string>
|
||||
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
|
||||
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
|
||||
<string name="auto_prices">Pricing</string>
|
||||
<string name="auto_vehicle_data">Vehicle data</string>
|
||||
<string name="auto_charging_level">Charging level</string>
|
||||
<string name="auto_no_data">Unavailable</string>
|
||||
<string name="auto_range">Range</string>
|
||||
<string name="auto_speed">Speed</string>
|
||||
<string name="welcome_android_auto">Android Auto support</string>
|
||||
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
|
||||
<string name="sounds_cool">sounds cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
|
||||
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
|
||||
</resources>
|
||||
14
app/src/google/res/xml/settings_android_auto.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<net.vonforst.evmap.ui.RangeSliderPreference
|
||||
android:key="chargeprice_battery_range_android_auto"
|
||||
android:title="@string/settings_android_auto_chargeprice_range"
|
||||
android:valueFrom="0.0"
|
||||
android:valueTo="100.0"
|
||||
app:updatesContinuously="true"
|
||||
android:defaultValue="20.0,80.0"
|
||||
android:layout="@layout/preference_widget_rangeslider"
|
||||
tools:summary="@string/chargeprice_battery_range" />
|
||||
</PreferenceScreen>
|
||||
7
app/src/google/res/xml/settings_variantspecific.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<Preference
|
||||
android:fragment="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
|
||||
android:title="@string/settings_android_auto"
|
||||
android:icon="@drawable/ic_android_auto" />
|
||||
</PreferenceScreen>
|
||||
@@ -3,6 +3,7 @@
|
||||
package="net.vonforst.evmap">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<queries>
|
||||
@@ -31,8 +32,9 @@
|
||||
|
||||
<activity
|
||||
android:name=".MapsActivity"
|
||||
android:label="@string/title_activity_maps"
|
||||
android:theme="@style/AppTheme.LaunchScreen">
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.LaunchScreen"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -254,8 +256,22 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<!-- Override services of the com.mapzen.android.lost library with exported:false
|
||||
until https://github.com/lostzen/lost/pull/270 is merged -->
|
||||
<service
|
||||
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
321
app/src/main/java/android/widget/Filter.java
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2007 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package android.widget;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Copy of android.widget.Filter, exposing the hidden setDelayer() method.
|
||||
*
|
||||
* <p>A filter constrains data with a filtering pattern.</p>
|
||||
*
|
||||
* <p>Filters are usually created by {@link android.widget.Filterable}
|
||||
* classes.</p>
|
||||
*
|
||||
* <p>Filtering operations performed by calling {@link #filter(CharSequence)} or
|
||||
* {@link #filter(CharSequence, android.widget.Filter.FilterListener)} are
|
||||
* performed asynchronously. When these methods are called, a filtering request
|
||||
* is posted in a request queue and processed later. Any call to one of these
|
||||
* methods will cancel any previous non-executed filtering request.</p>
|
||||
*
|
||||
* @see android.widget.Filterable
|
||||
*/
|
||||
public abstract class Filter {
|
||||
private static final String LOG_TAG = "Filter";
|
||||
|
||||
private static final String THREAD_NAME = "Filter";
|
||||
private static final int FILTER_TOKEN = 0xD0D0F00D;
|
||||
private static final int FINISH_TOKEN = 0xDEADBEEF;
|
||||
|
||||
private Handler mThreadHandler;
|
||||
private Handler mResultHandler;
|
||||
|
||||
private Delayer mDelayer;
|
||||
|
||||
private final Object mLock = new Object();
|
||||
|
||||
/**
|
||||
* <p>Creates a new asynchronous filter.</p>
|
||||
*/
|
||||
public Filter() {
|
||||
mResultHandler = new ResultsHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide an interface that decides how long to delay the message for a given query. Useful
|
||||
* for heuristics such as posting a delay for the delete key to avoid doing any work while the
|
||||
* user holds down the delete key.
|
||||
*
|
||||
* @param delayer The delayer.
|
||||
* @hide
|
||||
*/
|
||||
public void setDelayer(Delayer delayer) {
|
||||
synchronized (mLock) {
|
||||
mDelayer = delayer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Starts an asynchronous filtering operation. Calling this method
|
||||
* cancels all previous non-executed filtering requests and posts a new
|
||||
* filtering request that will be executed later.</p>
|
||||
*
|
||||
* @param constraint the constraint used to filter the data
|
||||
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
|
||||
*/
|
||||
public final void filter(CharSequence constraint) {
|
||||
filter(constraint, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Starts an asynchronous filtering operation. Calling this method
|
||||
* cancels all previous non-executed filtering requests and posts a new
|
||||
* filtering request that will be executed later.</p>
|
||||
*
|
||||
* <p>Upon completion, the listener is notified.</p>
|
||||
*
|
||||
* @param constraint the constraint used to filter the data
|
||||
* @param listener a listener notified upon completion of the operation
|
||||
* @see #filter(CharSequence)
|
||||
* @see #performFiltering(CharSequence)
|
||||
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
|
||||
*/
|
||||
public final void filter(CharSequence constraint, FilterListener listener) {
|
||||
synchronized (mLock) {
|
||||
if (mThreadHandler == null) {
|
||||
HandlerThread thread = new HandlerThread(
|
||||
THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND);
|
||||
thread.start();
|
||||
mThreadHandler = new RequestHandler(thread.getLooper());
|
||||
}
|
||||
|
||||
final long delay = (mDelayer == null) ? 0 : mDelayer.getPostingDelay(constraint);
|
||||
|
||||
Message message = mThreadHandler.obtainMessage(FILTER_TOKEN);
|
||||
|
||||
RequestArguments args = new RequestArguments();
|
||||
// make sure we use an immutable copy of the constraint, so that
|
||||
// it doesn't change while the filter operation is in progress
|
||||
args.constraint = constraint != null ? constraint.toString() : null;
|
||||
args.listener = listener;
|
||||
message.obj = args;
|
||||
|
||||
mThreadHandler.removeMessages(FILTER_TOKEN);
|
||||
mThreadHandler.removeMessages(FINISH_TOKEN);
|
||||
mThreadHandler.sendMessageDelayed(message, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Invoked in a worker thread to filter the data according to the
|
||||
* constraint. Subclasses must implement this method to perform the
|
||||
* filtering operation. Results computed by the filtering operation
|
||||
* must be returned as a {@link android.widget.Filter.FilterResults} that
|
||||
* will then be published in the UI thread through
|
||||
* {@link #publishResults(CharSequence,
|
||||
* android.widget.Filter.FilterResults)}.</p>
|
||||
*
|
||||
* <p><strong>Contract:</strong> When the constraint is null, the original
|
||||
* data must be restored.</p>
|
||||
*
|
||||
* @param constraint the constraint used to filter the data
|
||||
* @return the results of the filtering operation
|
||||
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
|
||||
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
|
||||
* @see android.widget.Filter.FilterResults
|
||||
*/
|
||||
protected abstract FilterResults performFiltering(CharSequence constraint);
|
||||
|
||||
/**
|
||||
* <p>Invoked in the UI thread to publish the filtering results in the
|
||||
* user interface. Subclasses must implement this method to display the
|
||||
* results computed in {@link #performFiltering}.</p>
|
||||
*
|
||||
* @param constraint the constraint used to filter the data
|
||||
* @param results the results of the filtering operation
|
||||
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
|
||||
* @see #performFiltering(CharSequence)
|
||||
* @see android.widget.Filter.FilterResults
|
||||
*/
|
||||
protected abstract void publishResults(CharSequence constraint,
|
||||
FilterResults results);
|
||||
|
||||
/**
|
||||
* <p>Converts a value from the filtered set into a CharSequence. Subclasses
|
||||
* should override this method to convert their results. The default
|
||||
* implementation returns an empty String for null values or the default
|
||||
* String representation of the value.</p>
|
||||
*
|
||||
* @param resultValue the value to convert to a CharSequence
|
||||
* @return a CharSequence representing the value
|
||||
*/
|
||||
public CharSequence convertResultToString(Object resultValue) {
|
||||
return resultValue == null ? "" : resultValue.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Holds the results of a filtering operation. The results are the values
|
||||
* computed by the filtering operation and the number of these values.</p>
|
||||
*/
|
||||
protected static class FilterResults {
|
||||
public FilterResults() {
|
||||
// nothing to see here
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Contains all the values computed by the filtering operation.</p>
|
||||
*/
|
||||
public Object values;
|
||||
|
||||
/**
|
||||
* <p>Contains the number of values computed by the filtering
|
||||
* operation.</p>
|
||||
*/
|
||||
public int count;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Listener used to receive a notification upon completion of a filtering
|
||||
* operation.</p>
|
||||
*/
|
||||
public static interface FilterListener {
|
||||
/**
|
||||
* <p>Notifies the end of a filtering operation.</p>
|
||||
*
|
||||
* @param count the number of values computed by the filter
|
||||
*/
|
||||
public void onFilterComplete(int count);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Worker thread handler. When a new filtering request is posted from
|
||||
* {@link android.widget.Filter#filter(CharSequence, android.widget.Filter.FilterListener)},
|
||||
* it is sent to this handler.</p>
|
||||
*/
|
||||
private class RequestHandler extends Handler {
|
||||
public RequestHandler(Looper looper) {
|
||||
super(looper);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Handles filtering requests by calling
|
||||
* {@link Filter#performFiltering} and then sending a message
|
||||
* with the results to the results handler.</p>
|
||||
*
|
||||
* @param msg the filtering request
|
||||
*/
|
||||
public void handleMessage(Message msg) {
|
||||
int what = msg.what;
|
||||
Message message;
|
||||
switch (what) {
|
||||
case FILTER_TOKEN:
|
||||
RequestArguments args = (RequestArguments) msg.obj;
|
||||
try {
|
||||
args.results = performFiltering(args.constraint);
|
||||
} catch (Exception e) {
|
||||
args.results = new FilterResults();
|
||||
Log.w(LOG_TAG, "An exception occured during performFiltering()!", e);
|
||||
} finally {
|
||||
message = mResultHandler.obtainMessage(what);
|
||||
message.obj = args;
|
||||
message.sendToTarget();
|
||||
}
|
||||
|
||||
synchronized (mLock) {
|
||||
if (mThreadHandler != null) {
|
||||
Message finishMessage = mThreadHandler.obtainMessage(FINISH_TOKEN);
|
||||
mThreadHandler.sendMessageDelayed(finishMessage, 3000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FINISH_TOKEN:
|
||||
synchronized (mLock) {
|
||||
if (mThreadHandler != null) {
|
||||
mThreadHandler.getLooper().quit();
|
||||
mThreadHandler = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Handles the results of a filtering operation. The results are
|
||||
* handled in the UI thread.</p>
|
||||
*/
|
||||
private class ResultsHandler extends Handler {
|
||||
/**
|
||||
* <p>Messages received from the request handler are processed in the
|
||||
* UI thread. The processing involves calling
|
||||
* {@link Filter#publishResults(CharSequence,
|
||||
* android.widget.Filter.FilterResults)}
|
||||
* to post the results back in the UI and then notifying the listener,
|
||||
* if any.</p>
|
||||
*
|
||||
* @param msg the filtering results
|
||||
*/
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
RequestArguments args = (RequestArguments) msg.obj;
|
||||
|
||||
publishResults(args.constraint, args.results);
|
||||
if (args.listener != null) {
|
||||
int count = args.results != null ? args.results.count : -1;
|
||||
args.listener.onFilterComplete(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Holds the arguments of a filtering request as well as the results
|
||||
* of the request.</p>
|
||||
*/
|
||||
private static class RequestArguments {
|
||||
/**
|
||||
* <p>The constraint used to filter the data.</p>
|
||||
*/
|
||||
CharSequence constraint;
|
||||
|
||||
/**
|
||||
* <p>The listener to notify upon completion. Can be null.</p>
|
||||
*/
|
||||
FilterListener listener;
|
||||
|
||||
/**
|
||||
* <p>The results of the filtering operation.</p>
|
||||
*/
|
||||
FilterResults results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
public interface Delayer {
|
||||
|
||||
/**
|
||||
* @param constraint The constraint passed to {@link Filter#filter(CharSequence)}
|
||||
* @return The delay that should be used for
|
||||
* {@link Handler#sendMessageDelayed(android.os.Message, long)}
|
||||
*/
|
||||
long getPostingDelay(CharSequence constraint);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import android.app.Application
|
||||
import com.facebook.stetho.Stetho
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.updateNightMode
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.limiter
|
||||
import org.acra.config.mailSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
|
||||
class EvMapApplication : Application() {
|
||||
override fun onCreate() {
|
||||
@@ -11,5 +16,28 @@ class EvMapApplication : Application() {
|
||||
updateNightMode(PreferenceDataSource(this))
|
||||
Stetho.initializeWithDefaults(this);
|
||||
init(applicationContext)
|
||||
|
||||
if (!BuildConfig.DEBUG) {
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.KEY_VALUE_LIST
|
||||
|
||||
mailSender {
|
||||
mailTo = "evmap+crashreport@vonforst.net"
|
||||
}
|
||||
|
||||
dialog {
|
||||
text = getString(R.string.crash_report_text)
|
||||
title = getString(R.string.app_name)
|
||||
commentPrompt = getString(R.string.crash_report_comment_prompt)
|
||||
resIcon = R.drawable.ic_launcher_foreground
|
||||
resTheme = R.style.AppTheme
|
||||
}
|
||||
|
||||
limiter {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,36 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.FragmentNavigator
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import net.vonforst.evmap.fragment.MapFragment
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.fragment.MapFragmentArgs
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.navigation.NavHostFragment
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.utils.LocaleContextWrapper
|
||||
import net.vonforst.evmap.utils.getLocationFromIntent
|
||||
@@ -30,8 +40,10 @@ const val REQUEST_LOCATION_PERMISSION = 1
|
||||
const val EXTRA_CHARGER_ID = "chargerId"
|
||||
const val EXTRA_LAT = "lat"
|
||||
const val EXTRA_LON = "lon"
|
||||
const val EXTRA_FAVORITES = "favorites"
|
||||
|
||||
class MapsActivity : AppCompatActivity() {
|
||||
class MapsActivity : AppCompatActivity(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
||||
interface FragmentCallback {
|
||||
fun getRootView(): View
|
||||
}
|
||||
@@ -51,14 +63,11 @@ class MapsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// set theme to AppTheme to end launch screen
|
||||
setTheme(R.style.AppTheme)
|
||||
super.onCreate(savedInstanceState)
|
||||
val splashScreen = installSplashScreen()
|
||||
|
||||
setContentView(R.layout.activity_maps)
|
||||
|
||||
navController = findNavController(R.id.nav_host_fragment)
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.map,
|
||||
@@ -68,6 +77,10 @@ class MapsActivity : AppCompatActivity() {
|
||||
),
|
||||
findViewById<DrawerLayout>(R.id.drawer_layout)
|
||||
)
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
navController = navHostFragment.navController
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
|
||||
val navView = findViewById<NavigationView>(R.id.nav_view)
|
||||
navView.setupWithNavController(navController)
|
||||
|
||||
@@ -78,63 +91,83 @@ class MapsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
prefs = PreferenceDataSource(this)
|
||||
prefs.appStartCounter += 1
|
||||
|
||||
checkPlayServices(this)
|
||||
|
||||
|
||||
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
|
||||
navGraph.startDestination = R.id.onboarding
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// wait for splash screen animation to finish on first start
|
||||
splashScreen.setKeepVisibleCondition(object : SplashScreen.KeepOnScreenCondition {
|
||||
var startTime: Long? = null
|
||||
|
||||
override fun shouldKeepOnScreen(): Boolean {
|
||||
val st = startTime
|
||||
if (st == null) {
|
||||
startTime = SystemClock.uptimeMillis()
|
||||
return true
|
||||
} else {
|
||||
return (SystemClock.uptimeMillis() - st) < 1000
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
navGraph.setStartDestination(R.id.onboarding)
|
||||
navController.graph = navGraph
|
||||
return
|
||||
} else {
|
||||
navGraph.startDestination = R.id.map
|
||||
navController.graph = navGraph
|
||||
}
|
||||
navGraph.setStartDestination(R.id.map)
|
||||
navController.setGraph(navGraph, MapFragmentArgs(appStart = true).toBundle())
|
||||
var deepLink: PendingIntent? = null
|
||||
|
||||
if (intent?.scheme == "geo") {
|
||||
val query = intent.data?.query?.split("=")?.get(1)
|
||||
val coords = getLocationFromIntent(intent)
|
||||
|
||||
if (intent?.scheme == "geo") {
|
||||
val query = intent.data?.query?.split("=")?.get(1)
|
||||
val coords = getLocationFromIntent(intent)
|
||||
|
||||
if (coords != null) {
|
||||
val lat = coords[0]
|
||||
val lon = coords[1]
|
||||
val deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
if (coords != null) {
|
||||
val lat = coords[0]
|
||||
val lon = coords[1]
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
|
||||
.createPendingIntent()
|
||||
} else if (query != null && query.isNotEmpty()) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(locationName = query).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragment.showLocation(lat, lon))
|
||||
.createPendingIntent()
|
||||
deepLink.send()
|
||||
} else if (query != null && query.isNotEmpty()) {
|
||||
val deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragment.showLocationByName(query))
|
||||
.createPendingIntent()
|
||||
deepLink.send()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
val deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragment.showChargerById(id))
|
||||
.createPendingIntent()
|
||||
deepLink.send()
|
||||
}
|
||||
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
|
||||
navController.createDeepLink()
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(
|
||||
MapFragment.showCharger(
|
||||
intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
.setArguments(
|
||||
MapFragmentArgs(
|
||||
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
|
||||
latLng = LatLng(
|
||||
intent.getDoubleExtra(EXTRA_LAT, 0.0),
|
||||
intent.getDoubleExtra(EXTRA_LON, 0.0)
|
||||
)
|
||||
).toBundle()
|
||||
)
|
||||
)
|
||||
.createPendingIntent()
|
||||
.send()
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setDestination(R.id.favs)
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
deepLink?.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +176,7 @@ class MapsActivity : AppCompatActivity() {
|
||||
val coord = charger.coordinates
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse("google.navigation:q=${coord.lat},${coord.lng}")
|
||||
intent.`package` = "com.google.android.apps.maps"
|
||||
if (prefs.navigateUseMaps && intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent);
|
||||
} else {
|
||||
@@ -154,7 +188,11 @@ class MapsActivity : AppCompatActivity() {
|
||||
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})")
|
||||
intent.data = Uri.parse(
|
||||
"geo:${coord.lat},${coord.lng}?q=${coord.lat},${coord.lng}(${
|
||||
Uri.encode(charger.name)
|
||||
})"
|
||||
)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent);
|
||||
} else {
|
||||
@@ -194,4 +232,18 @@ class MapsActivity : AppCompatActivity() {
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference
|
||||
): Boolean {
|
||||
caller.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
caller.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
|
||||
// Identify the Navigation Destination
|
||||
val navDestination = navController.graph
|
||||
.find { target -> target is FragmentNavigator.Destination && pref.fragment == target.className }
|
||||
navDestination?.let { target -> navController.navigate(target.id) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.*
|
||||
@@ -9,6 +11,7 @@ import androidx.lifecycle.Observer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
fun Bundle.optDouble(name: String): Double? {
|
||||
if (!this.containsKey(name)) return null
|
||||
@@ -80,6 +83,8 @@ fun max(a: Int?, b: Int?): Int? {
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> List<T>.containsAny(vararg values: T) = values.any { this.contains(it) }
|
||||
|
||||
public suspend fun <T> LiveData<T>.await(): T {
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
@@ -97,4 +102,14 @@ public suspend fun <T> LiveData<T>.await(): T {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.isDarkMode() =
|
||||
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
const val kmPerMile = 1.609344
|
||||
const val meterPerFt = 0.3048
|
||||
|
||||
fun shouldUseImperialUnits(): Boolean {
|
||||
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
|
||||
}
|
||||
@@ -144,10 +144,9 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
field = value
|
||||
checkedItem?.let {
|
||||
if (value != null && getItem(it).type !in value) {
|
||||
val index = currentList.indexOfFirst {
|
||||
checkedItem = currentList.indexOfFirst {
|
||||
it.type in value
|
||||
}
|
||||
checkedItem = if (index == -1) null else index
|
||||
}.takeIf { it != -1 }
|
||||
onCheckedItemChangedListener?.invoke(getCheckedItem())
|
||||
}
|
||||
}
|
||||
@@ -168,7 +167,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
}
|
||||
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
|
||||
if (checked) {
|
||||
checkedItem = position
|
||||
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
|
||||
root.post {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
@@ -180,7 +179,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
|
||||
|
||||
fun setCheckedItem(item: Chargepoint?) {
|
||||
checkedItem = item?.let { currentList.indexOf(item) }
|
||||
checkedItem = item?.let { currentList.indexOf(item) }.takeIf { it != -1 }
|
||||
}
|
||||
|
||||
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
|
||||
|
||||
@@ -73,7 +73,7 @@ fun buildDetails(
|
||||
)
|
||||
} ?: "",
|
||||
loc.faultReport.description?.let {
|
||||
HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
HtmlCompat.fromHtml(it.replace("\n", "<br>"), HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
} ?: "",
|
||||
clickable = true
|
||||
) else null,
|
||||
@@ -84,10 +84,10 @@ fun buildDetails(
|
||||
loc.openinghours.getStatusText(ctx)
|
||||
else
|
||||
loc.openinghours.description ?: "",
|
||||
if (loc.openinghours.days != null) loc.openinghours.description else null,
|
||||
if (loc.openinghours.days != null || loc.openinghours.twentyfourSeven) loc.openinghours.description else null,
|
||||
hoursDays = loc.openinghours.days
|
||||
) else null,
|
||||
if (loc.cost != null) DetailsAdapter.Detail(
|
||||
if (loc.cost != null && !loc.cost.isEmpty) DetailsAdapter.Detail(
|
||||
R.drawable.ic_cost,
|
||||
R.string.cost,
|
||||
loc.cost.getStatusText(ctx),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package net.vonforst.evmap.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.car2go.maps.model.LatLng
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.autocomplete.*
|
||||
import net.vonforst.evmap.containsAny
|
||||
import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding
|
||||
import net.vonforst.evmap.isDarkMode
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.storage.RecentAutocompletePlace
|
||||
import java.time.Instant
|
||||
|
||||
class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatLng>) :
|
||||
BaseAdapter(), Filterable {
|
||||
private var resultList: List<AutocompletePlace>? = null
|
||||
private val providers = getAutocompleteProviders(context)
|
||||
private val typeItem = 0
|
||||
private val typeAttribution = 1
|
||||
private val maxItems = 6
|
||||
private var currentProvider: AutocompleteProvider? = null
|
||||
private val recents = AppDatabase.getInstance(context).recentAutocompletePlaceDao()
|
||||
private var recentResults = mutableListOf<RecentAutocompletePlace>()
|
||||
|
||||
data class ViewHolder(val binding: ItemAutocompleteResultBinding)
|
||||
|
||||
override fun getCount(): Int {
|
||||
return resultList?.let { it.size + 1 } ?: 0
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): AutocompletePlace? {
|
||||
return if (position < resultList!!.size) resultList!![position] else null
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position < resultList!!.size) typeItem else typeAttribution
|
||||
}
|
||||
|
||||
override fun getViewTypeCount(): Int = 2
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
var view = convertView
|
||||
if (getItemViewType(position) == typeItem) {
|
||||
val viewHolder: ViewHolder
|
||||
if (view == null) {
|
||||
val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(context),
|
||||
R.layout.item_autocomplete_result,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
view = binding.root
|
||||
viewHolder = ViewHolder(binding)
|
||||
view.tag = viewHolder
|
||||
} else {
|
||||
viewHolder = view.tag as ViewHolder
|
||||
}
|
||||
val place = resultList!![position]
|
||||
bindView(viewHolder, place)
|
||||
} else if (getItemViewType(position) == typeAttribution) {
|
||||
if (view == null) {
|
||||
view = LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_autocomplete_attribution, parent, false)
|
||||
}
|
||||
(view as ImageView).apply {
|
||||
setImageResource(currentProvider?.getAttributionImage(context.isDarkMode()) ?: 0)
|
||||
contentDescription = context.getString(currentProvider?.getAttributionString() ?: 0)
|
||||
}
|
||||
|
||||
}
|
||||
return view!!
|
||||
}
|
||||
|
||||
private fun bindView(
|
||||
viewHolder: ViewHolder,
|
||||
place: AutocompletePlace
|
||||
) {
|
||||
viewHolder.binding.item = place
|
||||
}
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object : Filter() {
|
||||
var delaySet = false
|
||||
|
||||
init {
|
||||
if (PreferenceDataSource(context).searchProvider == "mapbox") {
|
||||
// set delay to 500 ms to reduce paid Mapbox API requests
|
||||
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
|
||||
}
|
||||
}
|
||||
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
|
||||
resultList = results?.values as? List<AutocompletePlace>?
|
||||
if (results != null && results.count > 0) {
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val query = constraint.toString()
|
||||
var resultList: List<AutocompletePlace>? = null
|
||||
if (constraint != null) {
|
||||
for (provider in providers) {
|
||||
try {
|
||||
recentResults.clear()
|
||||
currentProvider = provider
|
||||
|
||||
// first search in recent places
|
||||
val recentPlaces = if (query.isEmpty()) {
|
||||
recents.getAll(provider.id, limit = maxItems)
|
||||
} else {
|
||||
recents.search(query, provider.id, limit = maxItems)
|
||||
}
|
||||
recentResults.addAll(recentPlaces)
|
||||
resultList = recentPlaces.map { it.asAutocompletePlace(location.value) }
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
// publish intermediate results on main thread
|
||||
publishResults(constraint, resultList.asFilterResults())
|
||||
}
|
||||
|
||||
// if we already have enough results or the query is short, stop here
|
||||
if (isShortQuery(query) || recentResults.size >= maxItems) break
|
||||
|
||||
// then search online
|
||||
val recentIds = recentPlaces.map { it.id }
|
||||
resultList =
|
||||
(resultList!! + provider.autocomplete(query, location.value)
|
||||
.filter { !recentIds.contains(it.id) }).take(maxItems)
|
||||
break
|
||||
} catch (e: ApiUnavailableException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (currentProvider is MapboxAutocompleteProvider && !delaySet) {
|
||||
// set delay to 500 ms to reduce paid Mapbox API requests
|
||||
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
|
||||
}
|
||||
|
||||
return resultList.asFilterResults()
|
||||
}
|
||||
|
||||
private fun List<AutocompletePlace>?.asFilterResults(): FilterResults {
|
||||
val result = FilterResults()
|
||||
if (this != null) {
|
||||
result.values = this
|
||||
result.count = this.size
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShortQuery(query: CharSequence) = query.length < 3
|
||||
|
||||
suspend fun getDetails(id: String): PlaceWithBounds {
|
||||
val provider = currentProvider!!
|
||||
val result = resultList!!.find { it.id == id }!!
|
||||
|
||||
val recentPlace = recentResults.find { it.id == id }
|
||||
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
|
||||
|
||||
val details = provider.getDetails(id)
|
||||
|
||||
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun iconForPlaceType(types: List<AutocompletePlaceType>): Int =
|
||||
when {
|
||||
types.contains(
|
||||
AutocompletePlaceType.RECENT
|
||||
) -> R.drawable.ic_history
|
||||
types.containsAny(
|
||||
AutocompletePlaceType.LIGHT_RAIL_STATION,
|
||||
AutocompletePlaceType.BUS_STATION,
|
||||
AutocompletePlaceType.TRAIN_STATION,
|
||||
AutocompletePlaceType.TRANSIT_STATION
|
||||
) -> {
|
||||
R.drawable.ic_place_type_train
|
||||
}
|
||||
types.contains(AutocompletePlaceType.AIRPORT) -> {
|
||||
R.drawable.ic_place_type_airport
|
||||
}
|
||||
// TODO: extend this with icons for more place categories
|
||||
else -> {
|
||||
R.drawable.ic_place_type_default
|
||||
}
|
||||
}
|
||||
|
||||
fun isSpecialPlace(types: List<AutocompletePlaceType>): Boolean =
|
||||
!setOf(
|
||||
R.drawable.ic_place_type_default,
|
||||
R.drawable.ic_history
|
||||
).contains(iconForPlaceType(types))
|
||||
@@ -2,7 +2,6 @@ package net.vonforst.evmap.api.availability
|
||||
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.api.RateLimitInterceptor
|
||||
import net.vonforst.evmap.api.await
|
||||
@@ -24,7 +23,6 @@ interface AvailabilityDetector {
|
||||
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
|
||||
protected val radius = 150 // max radius in meters
|
||||
|
||||
@@ -65,10 +63,20 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
connectors: Map<Long, Pair<Double, String>>,
|
||||
chargepoints: List<Chargepoint>
|
||||
): Map<Chargepoint, Set<Long>> {
|
||||
var chargepoints = chargepoints
|
||||
|
||||
// iterate over each connector type
|
||||
val types = connectors.map { it.value.second }.distinct().toSet()
|
||||
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
|
||||
val geTypes = chargepoints.map { it.type }.distinct().toSet()
|
||||
var geTypes = chargepoints.map { it.type }.distinct().toSet()
|
||||
if (!equivalentTypes.any { it == geTypes } && geTypes.size > 1 && geTypes.contains(
|
||||
Chargepoint.SCHUKO
|
||||
)) {
|
||||
// If charger has household plugs and other plugs, try removing the household plugs
|
||||
// (common e.g. in Hamburg -> 2x Type 2 + 2x Schuko, but NM only lists Type 2)
|
||||
geTypes = geTypes.filter { it != Chargepoint.SCHUKO }.toSet()
|
||||
chargepoints = chargepoints.filter { it.type != Chargepoint.SCHUKO }
|
||||
}
|
||||
if (!equivalentTypes.any { it == geTypes }) throw AvailabilityDetectorException("chargepoints do not match")
|
||||
return types.flatMap { type ->
|
||||
// find connectors of this type
|
||||
@@ -92,7 +100,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
chargepoint to ids
|
||||
}
|
||||
} else if (powers.size == 1 && gePowers.size == 2
|
||||
&& chargepoints.sumBy { it.count } == connsOfType.size
|
||||
&& chargepoints.sumOf { it.count } == connsOfType.size
|
||||
) {
|
||||
// special case: dual charger(s) with load balancing
|
||||
// GoingElectric shows 2 different powers, NewMotion just one
|
||||
@@ -100,7 +108,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
var i = 0
|
||||
gePowers.map { gePower ->
|
||||
val chargepoint =
|
||||
chargepoints.find { it.type == type && it.power == gePower }!!
|
||||
chargepoints.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
|
||||
val ids = allIds.subList(i, i + chargepoint.count).toSet()
|
||||
i += chargepoint.count
|
||||
chargepoint to ids
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import net.vonforst.evmap.api.iterator
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
@@ -8,12 +7,10 @@ import okhttp3.OkHttpClient
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class ChargecloudAvailabilityDetector(
|
||||
client: OkHttpClient,
|
||||
private val operatorId: String
|
||||
) : BaseAvailabilityDetector(client) {
|
||||
@ExperimentalCoroutinesApi
|
||||
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
|
||||
val url =
|
||||
"https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/locations?latitude=${location.coordinates.lat}&longitude=${location.coordinates.lng}&radius=$radius&offset=0&limit=10"
|
||||
|
||||
@@ -15,6 +15,7 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import java.util.*
|
||||
|
||||
interface ChargepriceApi {
|
||||
@POST("charge_prices")
|
||||
@@ -33,6 +34,9 @@ interface ChargepriceApi {
|
||||
private val cacheSize = 1L * 1024 * 1024 // 1MB
|
||||
val supportedLanguages = setOf("de", "en", "fr", "nl")
|
||||
|
||||
val DATA_SOURCE_GOINGELECTRIC = "going_electric"
|
||||
val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
|
||||
|
||||
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
|
||||
.add(ChargepriceRequest::class.java)
|
||||
.add(ChargepriceTariff::class.java)
|
||||
@@ -74,5 +78,61 @@ interface ChargepriceApi {
|
||||
.build()
|
||||
return retrofit.create(ChargepriceApi::class.java)
|
||||
}
|
||||
|
||||
|
||||
fun getChargepriceLanguage(): String {
|
||||
val locale = Locale.getDefault().language
|
||||
return if (supportedLanguages.contains(locale)) {
|
||||
locale
|
||||
} else {
|
||||
"en"
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
|
||||
// list of countries updated 2021/08/24
|
||||
"goingelectric" -> country in listOf(
|
||||
"Deutschland",
|
||||
"Österreich",
|
||||
"Schweiz",
|
||||
"Frankreich",
|
||||
"Belgien",
|
||||
"Niederlande",
|
||||
"Luxemburg",
|
||||
"Dänemark",
|
||||
"Norwegen",
|
||||
"Schweden",
|
||||
"Slowenien",
|
||||
"Kroatien",
|
||||
"Ungarn",
|
||||
"Tschechien",
|
||||
"Italien",
|
||||
"Spanien",
|
||||
"Großbritannien",
|
||||
"Irland"
|
||||
)
|
||||
"openchargemap" -> country in listOf(
|
||||
"DE",
|
||||
"AT",
|
||||
"CH",
|
||||
"FR",
|
||||
"BE",
|
||||
"NE",
|
||||
"LU",
|
||||
"DK",
|
||||
"NO",
|
||||
"SE",
|
||||
"SI",
|
||||
"HR",
|
||||
"HU",
|
||||
"CZ",
|
||||
"IT",
|
||||
"ES",
|
||||
"GB",
|
||||
"IE"
|
||||
)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.ui.currency
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
@@ -148,6 +149,26 @@ class ChargepriceCar : Resource(), Equatable {
|
||||
result = 31 * result + manufacturer.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
private val acConnectors = listOf(
|
||||
Chargepoint.CEE_BLAU,
|
||||
Chargepoint.CEE_ROT,
|
||||
Chargepoint.SCHUKO,
|
||||
Chargepoint.TYPE_1,
|
||||
Chargepoint.TYPE_2_UNKNOWN,
|
||||
Chargepoint.TYPE_2_SOCKET,
|
||||
Chargepoint.TYPE_2_PLUG
|
||||
)
|
||||
private val plugMapping = mapOf(
|
||||
"ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"tesla_suc" to Chargepoint.SUPERCHARGER,
|
||||
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
|
||||
"chademo" to Chargepoint.CHADEMO
|
||||
)
|
||||
val compatibleEvmapConnectors: List<String>
|
||||
get() = dcChargePorts.map {
|
||||
plugMapping[it]
|
||||
}.filterNotNull().plus(acConnectors)
|
||||
}
|
||||
|
||||
@JsonApi(type = "brand")
|
||||
@@ -184,6 +205,9 @@ class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
@field:Json(name = "charge_point_prices")
|
||||
lateinit var chargepointPrices: List<ChargepointPrice>
|
||||
|
||||
@field:Json(name = "branding")
|
||||
var branding: ChargepriceBranding? = null
|
||||
|
||||
var tariff: HasOne<ChargepriceTariff>? = null
|
||||
|
||||
|
||||
@@ -217,6 +241,7 @@ class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
if (startTime != other.startTime) return false
|
||||
if (tags != other.tags) return false
|
||||
if (chargepointPrices != other.chargepointPrices) return false
|
||||
if (branding != other.branding) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -235,6 +260,7 @@ class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
result = 31 * result + startTime
|
||||
result = 31 * result + tags.hashCode()
|
||||
result = 31 * result + chargepointPrices.hashCode()
|
||||
result = 31 * result + branding.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -253,6 +279,7 @@ class ChargePrice : Resource(), Equatable, Cloneable {
|
||||
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
|
||||
url = this@ChargePrice.url
|
||||
tariff = this@ChargePrice.tariff
|
||||
branding = this@ChargePrice.branding
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +334,12 @@ data class ChargepointPrice(
|
||||
}
|
||||
}
|
||||
|
||||
data class ChargepriceBranding(
|
||||
@Json(name = "background_color") val backgroundColor: String,
|
||||
@Json(name = "text_color") val textColor: String,
|
||||
@Json(name = "logo_url") val logoUrl: String
|
||||
)
|
||||
|
||||
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
|
||||
val isOnlyKwh =
|
||||
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
|
||||
@@ -318,6 +351,19 @@ data class ChargepriceMeta(
|
||||
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
|
||||
)
|
||||
|
||||
enum class ChargepriceInclude {
|
||||
@Json(name = "filter")
|
||||
FILTER,
|
||||
@Json(name = "always")
|
||||
ALWAYS,
|
||||
@Json(name = "exclusive")
|
||||
EXCLUSIVE
|
||||
}
|
||||
|
||||
data class ChargepriceRequestTariffMeta(
|
||||
val include: ChargepriceInclude
|
||||
)
|
||||
|
||||
data class ChargepriceChargepointMeta(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
|
||||
@@ -126,16 +126,21 @@ internal class HoursAdapter {
|
||||
private val regex = Regex("from (.*) till (.*)")
|
||||
|
||||
@FromJson
|
||||
fun fromJson(str: String): GEHours? {
|
||||
fun fromJson(str: String): GEHours {
|
||||
if (str == "closed") {
|
||||
return GEHours(null, null)
|
||||
} else if (str == "around the clock") {
|
||||
return GEHours(LocalTime.MIN, LocalTime.MAX)
|
||||
} else {
|
||||
val match = regex.find(str)
|
||||
if (match != null) {
|
||||
return GEHours(
|
||||
LocalTime.parse(match.groupValues[1]),
|
||||
val start = LocalTime.parse(match.groupValues[1])
|
||||
val end = if (match.groupValues[2] == "24:00") {
|
||||
LocalTime.MAX
|
||||
} else {
|
||||
LocalTime.parse(match.groupValues[2])
|
||||
)
|
||||
}
|
||||
return GEHours(start, end)
|
||||
} else {
|
||||
// I cannot reproduce this case, but it seems to occur once in a while
|
||||
Log.e("GoingElectricApi", "invalid hours value: " + str)
|
||||
|
||||
@@ -402,7 +402,7 @@ class GoingElectricApiWrapper(
|
||||
val chargeCards = referenceData.chargecards
|
||||
|
||||
val plugMap = plugs.map { plug ->
|
||||
plug to nameForPlugType(sp, plug)
|
||||
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
||||
}.toMap()
|
||||
val networkMap = networks.map { it to it }.toMap()
|
||||
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
|
||||
@@ -448,11 +448,11 @@ class GoingElectricApiWrapper(
|
||||
MultipleChoiceFilter(
|
||||
sp.getString(R.string.filter_connectors), "connectors",
|
||||
plugMap,
|
||||
commonChoices = setOf(
|
||||
commonChoices = listOf(
|
||||
Chargepoint.TYPE_2_UNKNOWN,
|
||||
Chargepoint.CCS_UNKNOWN,
|
||||
Chargepoint.CHADEMO
|
||||
),
|
||||
).map { GEChargepoint.convertTypeToGE(it)!! }.toSet(),
|
||||
manyChoices = true
|
||||
),
|
||||
SliderFilter(
|
||||
|
||||
@@ -56,6 +56,7 @@ data class GEChargeLocation(
|
||||
) : GEChargepointListItem() {
|
||||
override fun convert(apikey: String) = ChargeLocation(
|
||||
id,
|
||||
"goingelectric",
|
||||
name,
|
||||
coordinates.convert(),
|
||||
address.convert(),
|
||||
@@ -86,7 +87,14 @@ data class GECost(
|
||||
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
|
||||
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
|
||||
) {
|
||||
fun convert() = Cost(freecharging, freeparking, descriptionShort, descriptionLong)
|
||||
fun convert() = Cost(
|
||||
// In GE, freecharging = false can either mean "paid charging" or "no information
|
||||
// available", only freecharging = true provides useful information. Therefore convert
|
||||
// false to null. Same for freeparking.
|
||||
if (freecharging) freecharging else null,
|
||||
if (freeparking) freeparking else null,
|
||||
descriptionShort, descriptionLong
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@@ -125,7 +133,7 @@ data class GEHours(
|
||||
val start: LocalTime?,
|
||||
val end: LocalTime?
|
||||
) {
|
||||
fun convert() = Hours(start, end)
|
||||
fun convert() = if (start != null && end != null) Hours(start, end) else null
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
||||
@@ -46,6 +46,7 @@ data class OCMChargepoint(
|
||||
) {
|
||||
fun convert(refData: OCMReferenceData) = ChargeLocation(
|
||||
id,
|
||||
"openchargemap",
|
||||
addressInfo.title,
|
||||
Coordinate(addressInfo.latitude, addressInfo.longitude),
|
||||
addressInfo.toAddress(refData),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface AutocompleteProvider {
|
||||
val id: String
|
||||
|
||||
fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace>
|
||||
suspend fun getDetails(id: String): PlaceWithBounds
|
||||
|
||||
@StringRes
|
||||
fun getAttributionString(): Int
|
||||
|
||||
@DrawableRes
|
||||
fun getAttributionImage(dark: Boolean): Int
|
||||
}
|
||||
|
||||
data class AutocompletePlace(
|
||||
val primaryText: CharSequence,
|
||||
val secondaryText: CharSequence,
|
||||
val id: String,
|
||||
val distanceMeters: Double?,
|
||||
val types: List<AutocompletePlaceType>
|
||||
)
|
||||
|
||||
class ApiUnavailableException : Exception()
|
||||
|
||||
enum class AutocompletePlaceType {
|
||||
// based on Google Places Place.Type enum
|
||||
OTHER,
|
||||
ACCOUNTING,
|
||||
ADMINISTRATIVE_AREA_LEVEL_1,
|
||||
ADMINISTRATIVE_AREA_LEVEL_2,
|
||||
ADMINISTRATIVE_AREA_LEVEL_3,
|
||||
ADMINISTRATIVE_AREA_LEVEL_4,
|
||||
ADMINISTRATIVE_AREA_LEVEL_5,
|
||||
AIRPORT,
|
||||
AMUSEMENT_PARK,
|
||||
AQUARIUM,
|
||||
ARCHIPELAGO,
|
||||
ART_GALLERY,
|
||||
ATM,
|
||||
BAKERY,
|
||||
BANK,
|
||||
BAR,
|
||||
BEAUTY_SALON,
|
||||
BICYCLE_STORE,
|
||||
BOOK_STORE,
|
||||
BOWLING_ALLEY,
|
||||
BUS_STATION,
|
||||
CAFE,
|
||||
CAMPGROUND,
|
||||
CAR_DEALER,
|
||||
CAR_RENTAL,
|
||||
CAR_REPAIR,
|
||||
CAR_WASH,
|
||||
CASINO,
|
||||
CEMETERY,
|
||||
CHURCH,
|
||||
CITY_HALL,
|
||||
CLOTHING_STORE,
|
||||
COLLOQUIAL_AREA,
|
||||
CONTINENT,
|
||||
CONVENIENCE_STORE,
|
||||
COUNTRY,
|
||||
COURTHOUSE,
|
||||
DENTIST,
|
||||
DEPARTMENT_STORE,
|
||||
DOCTOR,
|
||||
DRUGSTORE,
|
||||
ELECTRICIAN,
|
||||
ELECTRONICS_STORE,
|
||||
EMBASSY,
|
||||
ESTABLISHMENT,
|
||||
FINANCE,
|
||||
FIRE_STATION,
|
||||
FLOOR,
|
||||
FLORIST,
|
||||
FOOD,
|
||||
FUNERAL_HOME,
|
||||
FURNITURE_STORE,
|
||||
GAS_STATION,
|
||||
GENERAL_CONTRACTOR,
|
||||
GEOCODE,
|
||||
GROCERY_OR_SUPERMARKET,
|
||||
GYM,
|
||||
HAIR_CARE,
|
||||
HARDWARE_STORE,
|
||||
HEALTH,
|
||||
HINDU_TEMPLE,
|
||||
HOME_GOODS_STORE,
|
||||
HOSPITAL,
|
||||
INSURANCE_AGENCY,
|
||||
INTERSECTION,
|
||||
JEWELRY_STORE,
|
||||
LAUNDRY,
|
||||
LAWYER,
|
||||
LIBRARY,
|
||||
LIGHT_RAIL_STATION,
|
||||
LIQUOR_STORE,
|
||||
LOCAL_GOVERNMENT_OFFICE,
|
||||
LOCALITY,
|
||||
LOCKSMITH,
|
||||
LODGING,
|
||||
MEAL_DELIVERY,
|
||||
MEAL_TAKEAWAY,
|
||||
MOSQUE,
|
||||
MOVIE_RENTAL,
|
||||
MOVIE_THEATER,
|
||||
MOVING_COMPANY,
|
||||
MUSEUM,
|
||||
NATURAL_FEATURE,
|
||||
NEIGHBORHOOD,
|
||||
NIGHT_CLUB,
|
||||
PAINTER,
|
||||
PARK,
|
||||
PARKING,
|
||||
PET_STORE,
|
||||
PHARMACY,
|
||||
PHYSIOTHERAPIST,
|
||||
PLACE_OF_WORSHIP,
|
||||
PLUMBER,
|
||||
PLUS_CODE,
|
||||
POINT_OF_INTEREST,
|
||||
POLICE,
|
||||
POLITICAL,
|
||||
POST_BOX,
|
||||
POST_OFFICE,
|
||||
POSTAL_CODE_PREFIX,
|
||||
POSTAL_CODE_SUFFIX,
|
||||
POSTAL_CODE,
|
||||
POSTAL_TOWN,
|
||||
PREMISE,
|
||||
PRIMARY_SCHOOL,
|
||||
REAL_ESTATE_AGENCY,
|
||||
RESTAURANT,
|
||||
ROOFING_CONTRACTOR,
|
||||
ROOM,
|
||||
ROUTE,
|
||||
RV_PARK,
|
||||
SCHOOL,
|
||||
SECONDARY_SCHOOL,
|
||||
SHOE_STORE,
|
||||
SHOPPING_MALL,
|
||||
SPA,
|
||||
STADIUM,
|
||||
STORAGE,
|
||||
STORE,
|
||||
STREET_ADDRESS,
|
||||
STREET_NUMBER,
|
||||
SUBLOCALITY_LEVEL_1,
|
||||
SUBLOCALITY_LEVEL_2,
|
||||
SUBLOCALITY_LEVEL_3,
|
||||
SUBLOCALITY_LEVEL_4,
|
||||
SUBLOCALITY_LEVEL_5,
|
||||
SUBLOCALITY,
|
||||
SUBPREMISE,
|
||||
SUBWAY_STATION,
|
||||
SUPERMARKET,
|
||||
SYNAGOGUE,
|
||||
TAXI_STAND,
|
||||
TOURIST_ATTRACTION,
|
||||
TOWN_SQUARE,
|
||||
TRAIN_STATION,
|
||||
TRANSIT_STATION,
|
||||
TRAVEL_AGENCY,
|
||||
UNIVERSITY,
|
||||
VETERINARY_CARE,
|
||||
ZOO,
|
||||
RECENT;
|
||||
|
||||
companion object {
|
||||
fun valueOfOrNull(value: String): AutocompletePlaceType? {
|
||||
try {
|
||||
return valueOf(value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?) : Parcelable
|
||||
@@ -0,0 +1,127 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.car2go.maps.util.SphericalUtil
|
||||
import com.mapbox.api.geocoding.v5.GeocodingCriteria
|
||||
import com.mapbox.api.geocoding.v5.MapboxGeocoding
|
||||
import com.mapbox.api.geocoding.v5.models.CarmenFeature
|
||||
import com.mapbox.geojson.BoundingBox
|
||||
import com.mapbox.geojson.Point
|
||||
import net.vonforst.evmap.R
|
||||
import java.io.IOException
|
||||
|
||||
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
|
||||
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
|
||||
private val results = HashMap<String, CarmenFeature>()
|
||||
|
||||
override val id = "mapbox"
|
||||
|
||||
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
|
||||
val result = MapboxGeocoding.builder().apply {
|
||||
location?.let {
|
||||
proximity(Point.fromLngLat(location.longitude, location.latitude))
|
||||
}
|
||||
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0].language)
|
||||
accessToken(context.getString(R.string.mapbox_key))
|
||||
autocomplete(true)
|
||||
this.query(query)
|
||||
}.build().executeCall()
|
||||
if (!result.isSuccessful) {
|
||||
throw IOException(result.message())
|
||||
}
|
||||
return result.body()!!.features().map { feature ->
|
||||
results[feature.id()!!] = feature
|
||||
var secondaryText = (feature.matchingPlaceName() ?: feature.placeName())!!
|
||||
|
||||
val matchingText = (feature.matchingText() ?: feature.text())!!
|
||||
val primaryText =
|
||||
if (feature.address() != null && secondaryText.startsWith(feature.address() + " " + matchingText)) {
|
||||
// countries where house number comes in front of road ("10 Downing Street")
|
||||
feature.address() + " " + matchingText
|
||||
} else {
|
||||
// countries where house number comes after road ("Willy-Brandt-Str. 1")
|
||||
matchingText + (feature.address()?.let { " $it" } ?: "")
|
||||
}
|
||||
|
||||
secondaryText = secondaryText.replace("$primaryText, ", "")
|
||||
AutocompletePlace(
|
||||
highlightMatch(primaryText, query),
|
||||
secondaryText,
|
||||
feature.id()!!,
|
||||
location?.let { location ->
|
||||
SphericalUtil.computeDistanceBetween(
|
||||
feature.center()!!.toLatLng(), location
|
||||
)
|
||||
},
|
||||
getPlaceTypes(feature)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPlaceTypes(feature: CarmenFeature): List<AutocompletePlaceType> {
|
||||
val types = feature.placeType()?.mapNotNull {
|
||||
when (it) {
|
||||
GeocodingCriteria.TYPE_COUNTRY -> AutocompletePlaceType.COUNTRY
|
||||
GeocodingCriteria.TYPE_REGION -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_1
|
||||
GeocodingCriteria.TYPE_POSTCODE -> AutocompletePlaceType.POSTAL_CODE
|
||||
GeocodingCriteria.TYPE_DISTRICT -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_2
|
||||
GeocodingCriteria.TYPE_PLACE -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_3
|
||||
GeocodingCriteria.TYPE_LOCALITY -> AutocompletePlaceType.LOCALITY
|
||||
GeocodingCriteria.TYPE_NEIGHBORHOOD -> AutocompletePlaceType.NEIGHBORHOOD
|
||||
GeocodingCriteria.TYPE_ADDRESS -> AutocompletePlaceType.STREET_ADDRESS
|
||||
GeocodingCriteria.TYPE_POI -> AutocompletePlaceType.POINT_OF_INTEREST
|
||||
GeocodingCriteria.TYPE_POI_LANDMARK -> AutocompletePlaceType.POINT_OF_INTEREST
|
||||
else -> null
|
||||
}
|
||||
} ?: emptyList()
|
||||
val categories = feature.properties()?.get("category")?.asString?.split(", ")?.mapNotNull {
|
||||
// Place categories are defined at https://docs.mapbox.com/api/search/geocoding/#point-of-interest-category-coverage
|
||||
// We try to find a matching entry in the enum.
|
||||
// TODO: map categories that are not named the same
|
||||
AutocompletePlaceType.valueOfOrNull(it.uppercase().replace(" ", "_"))
|
||||
} ?: emptyList()
|
||||
return types + categories
|
||||
}
|
||||
|
||||
private fun highlightMatch(text: String, query: String): CharSequence {
|
||||
val result = SpannableString(text)
|
||||
|
||||
val startPos = text.lowercase().indexOf(query.lowercase())
|
||||
if (startPos > -1) {
|
||||
val endPos = startPos + query.length
|
||||
result.setSpan(bold, startPos, endPos, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun getDetails(id: String): PlaceWithBounds {
|
||||
val place = results[id]!!
|
||||
results.clear()
|
||||
return PlaceWithBounds(
|
||||
place.center()!!.toLatLng(),
|
||||
place.geometry()?.bbox()?.toLatLngBounds()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAttributionString(): Int = R.string.powered_by_mapbox
|
||||
|
||||
override fun getAttributionImage(dark: Boolean): Int =
|
||||
if (dark) R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
|
||||
}
|
||||
|
||||
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
|
||||
return LatLngBounds(
|
||||
southwest().toLatLng(),
|
||||
northeast().toLatLng()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude())
|
||||
@@ -2,40 +2,34 @@ package net.vonforst.evmap.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import android.view.*
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.adapter.ChargepriceAdapter
|
||||
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
|
||||
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
|
||||
import net.vonforst.evmap.api.ChargepointApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
|
||||
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
|
||||
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.model.ReferenceData
|
||||
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
import java.text.NumberFormat
|
||||
|
||||
class ChargepriceFragment : DialogFragment() {
|
||||
class ChargepriceFragment : Fragment() {
|
||||
private lateinit var binding: FragmentChargepriceBinding
|
||||
private var connectionErrorSnackbar: Snackbar? = null
|
||||
|
||||
@@ -48,9 +42,9 @@ class ChargepriceFragment : DialogFragment() {
|
||||
}
|
||||
})
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
dialog?.window?.attributes?.windowAnimations = R.style.ChargepriceDialogAnimation
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedElementEnterTransition = MaterialContainerTransform()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@@ -71,30 +65,18 @@ class ChargepriceFragment : DialogFragment() {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// dialog with 95% screen height
|
||||
dialog?.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(resources.displayMetrics.heightPixels * 0.95).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
binding.toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
|
||||
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
|
||||
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
|
||||
val charger = fragmentArgs.charger
|
||||
val dataSource = fragmentArgs.dataSource
|
||||
vm.charger.value = charger
|
||||
vm.dataSource.value = dataSource
|
||||
if (vm.chargepoint.value == null) {
|
||||
@@ -161,11 +143,11 @@ class ChargepriceFragment : DialogFragment() {
|
||||
}
|
||||
|
||||
binding.imgChargepriceLogo.setOnClickListener {
|
||||
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=going_electric")
|
||||
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
|
||||
}
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
navController.navigate(R.id.action_chargeprice_to_settingsFragment)
|
||||
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
|
||||
}
|
||||
|
||||
binding.batteryRange.setLabelFormatter { value: Float ->
|
||||
@@ -183,8 +165,8 @@ class ChargepriceFragment : DialogFragment() {
|
||||
|
||||
binding.toolbar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_close -> {
|
||||
dismiss()
|
||||
R.id.menu_help -> {
|
||||
(activity as? MapsActivity)?.openUrl(getString(R.string.chargeprice_faq_link))
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
@@ -215,30 +197,9 @@ class ChargepriceFragment : DialogFragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_CHARGER = "charger"
|
||||
const val ARG_DATASOURCE = "datasource"
|
||||
|
||||
fun showCharger(
|
||||
charger: ChargeLocation,
|
||||
dataSource: Class<ChargepointApi<ReferenceData>>
|
||||
): Bundle {
|
||||
return Bundle().apply {
|
||||
putParcelable(
|
||||
ARG_CHARGER,
|
||||
charger
|
||||
)
|
||||
putString(
|
||||
ARG_DATASOURCE,
|
||||
when (dataSource) {
|
||||
GoingElectricApiWrapper::class.java -> "going_electric"
|
||||
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
|
||||
else -> throw IllegalArgumentException("unsupported data source")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// dialog with 95% screen height
|
||||
dialog?.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
(resources.displayMetrics.heightPixels * 0.95).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package net.vonforst.evmap.fragment
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Canvas
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
@@ -9,8 +7,6 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -21,7 +17,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.mapzen.android.lost.api.LocationServices
|
||||
import com.mapzen.android.lost.api.LostApiClient
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
@@ -31,12 +29,13 @@ import net.vonforst.evmap.adapter.FavoritesAdapter
|
||||
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
|
||||
import net.vonforst.evmap.databinding.ItemFavoriteBinding
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.utils.checkAnyLocationPermission
|
||||
import net.vonforst.evmap.viewmodel.FavoritesViewModel
|
||||
import net.vonforst.evmap.viewmodel.viewModelFactory
|
||||
|
||||
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
private lateinit var binding: FragmentFavoritesBinding
|
||||
private lateinit var locationClient: LostApiClient
|
||||
private var locationClient: LostApiClient? = null
|
||||
private var toDelete: ChargeLocation? = null
|
||||
private var deleteSnackbar: Snackbar? = null
|
||||
private lateinit var adapter: FavoritesAdapter
|
||||
@@ -50,11 +49,20 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
})
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this).build()
|
||||
|
||||
enterTransition = MaterialFadeThrough()
|
||||
exitTransition = MaterialFadeThrough()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
binding = DataBindingUtil.inflate(
|
||||
inflater,
|
||||
R.layout.fragment_favorites, container, false
|
||||
@@ -62,19 +70,16 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
binding.lifecycleOwner = this
|
||||
binding.vm = vm
|
||||
|
||||
locationClient = LostApiClient.Builder(requireContext())
|
||||
.addConnectionCallbacks(this).build()
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
binding.toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
@@ -82,7 +87,13 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
delete(it.charger)
|
||||
}).apply {
|
||||
onClickListener = {
|
||||
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
|
||||
findNavController().navigate(
|
||||
R.id.action_favs_to_map,
|
||||
MapFragmentArgs(
|
||||
chargerId = it.charger.id,
|
||||
latLng = LatLng(it.charger.coordinates.lat, it.charger.coordinates.lng)
|
||||
).toBundle()
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.favsList.apply {
|
||||
@@ -97,17 +108,13 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
createTouchHelper().attachToRecyclerView(binding.favsList)
|
||||
|
||||
locationClient.connect()
|
||||
locationClient!!.connect()
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
val context = this.context ?: return
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient!!)
|
||||
if (location != null) {
|
||||
vm.location.value = LatLng(location.latitude, location.longitude)
|
||||
}
|
||||
@@ -120,8 +127,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (locationClient.isConnected) {
|
||||
locationClient.disconnect()
|
||||
locationClient?.let {
|
||||
if (it.isConnected) it.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,4 +263,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package net.vonforst.evmap.fragment
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -12,6 +11,8 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
@@ -25,6 +26,12 @@ class FilterFragment : Fragment() {
|
||||
private lateinit var binding: FragmentFilterBinding
|
||||
private val vm: FilterViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -42,15 +49,19 @@ class FilterFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
|
||||
|
||||
val navController = findNavController()
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
binding.toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
|
||||
vm.filterProfile.observe(viewLifecycleOwner) {
|
||||
if (it != null) {
|
||||
binding.toolbar.title = getString(R.string.edit_filter_profile, it.name)
|
||||
}
|
||||
}
|
||||
|
||||
binding.filtersList.apply {
|
||||
adapter = FiltersAdapter()
|
||||
layoutManager =
|
||||
@@ -62,9 +73,12 @@ class FilterFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
|
||||
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
@@ -82,26 +96,38 @@ class FilterFragment : Fragment() {
|
||||
true
|
||||
}
|
||||
R.id.menu_save_profile -> {
|
||||
showEditTextDialog(requireContext()) { dialog, input ->
|
||||
vm.filterProfile.value?.let { profile ->
|
||||
input.setText(profile.name)
|
||||
}
|
||||
|
||||
dialog.setTitle(R.string.save_as_profile)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
lifecycleScope.launch {
|
||||
vm.saveAsProfile(input.text.toString())
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
|
||||
}
|
||||
}
|
||||
saveProfile()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveProfile(error: Boolean = false) {
|
||||
showEditTextDialog(requireContext()) { dialog, input ->
|
||||
vm.filterProfile.value?.let { profile ->
|
||||
input.setText(profile.name)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
input.error = getString(R.string.required)
|
||||
}
|
||||
|
||||
dialog.setTitle(R.string.save_as_profile)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
if (input.text.isBlank()) {
|
||||
saveProfile(true)
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
vm.saveAsProfile(input.text.toString())
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||