Compare commits

..

103 Commits
0.9.0 ... 1.0.0

Author SHA1 Message Date
johan12345
23387ae371 Release 1.0.0 2021-10-02 14:08:44 +02:00
johan12345
25f466b6d7 Merge branch 'place-search-recents' 2021-10-02 13:49:25 +02:00
johan12345
6692b21bf9 add non-dark Mapbox logo 2021-10-02 13:48:50 +02:00
johan12345
5959fe8be4 limit number of autocomplete results 2021-10-02 13:40:22 +02:00
johan12345
00f4c13fcc add button to delete search history 2021-10-02 13:33:51 +02:00
johan12345
47054d470b replace deprecated method getAdapterPosition 2021-10-02 13:23:30 +02:00
Johan von Forstner
d10192cae1 save recent place search results
fixes #128
2021-10-02 13:19:33 +02:00
johan12345
e1b90955c3 handle back button correctly
fixes #115
2021-10-02 13:18:27 +02:00
johan12345
d249bf47c7 improve title of filter profiles editing 2021-10-02 13:15:58 +02:00
johan12345
738dcd5f8d do not allow blank names for filter profiles 2021-10-02 13:11:05 +02:00
johan12345
ad4f32ec32 fix missing filter name in title of FilterFragment 2021-10-02 12:47:22 +02:00
Johan von Forstner
4d03107ae7 add links to translated FAQ and privacy policy
#110
2021-09-26 16:17:44 +02:00
Johan von Forstner
0e93e310bf MultiSelectDialog: move selected entries to the top
fixes #126
2021-09-26 16:17:44 +02:00
Johan von Forstner
6cb8940696 Catch IllegalArgumentException in navigation library 2021-09-26 14:47:55 +02:00
johan12345
dad30eb51e Release 1.0.0-beta04 2021-09-19 19:37:52 +02:00
johan12345
abf6a2b933 upgrade some dependencies 2021-09-19 19:28:26 +02:00
johan12345
2c5685d918 Android Auto: use smartphone location even with car API level 3
if car hardware location is not available
2021-09-19 19:25:59 +02:00
johan12345
b61e57b022 Android Auto: fix crash when filter profile has no name 2021-09-19 19:25:59 +02:00
Johan von Forstner
e6428cc8db Merge pull request #125 from johan12345/android-12
Update to Android 12 targetSdkVersion and implement corresponding changes
2021-09-12 21:39:39 +02:00
johan12345
6302006a35 Android 12 compat: implement new SplashScreen API
with animated icon
update onboarding to avoid showing animation twice
2021-09-12 21:21:17 +02:00
johan12345
ab93577a98 Android 12 compat: support for approximate location permission (#123) 2021-09-12 21:21:17 +02:00
johan12345
98b695ed4b Android 12 compat: set exported attribute explicitly (#123)
for services and activities in Manifest
2021-09-12 18:48:15 +02:00
johan12345
ed8cb50b08 increase targetSdk to Android 12 (API 31) (#123) 2021-09-12 18:48:14 +02:00
johan12345
88d89c2760 fix crash in FavoritesFragment.onDestroy
apparently it can be called before onCreate?
2021-09-12 18:00:07 +02:00
johan12345
80c25cb416 build.gradle: add possibility to pass encrypted Mapbox API Key 2021-09-12 12:23:25 +02:00
johan12345
81c8e54dd2 Release 1.0.0-beta03 2021-09-12 11:49:09 +02:00
johan12345
8c01ee1581 shorten error message on Android Auto 2021-09-12 11:48:28 +02:00
johan12345
e8db5acfbf Check if Android Auto Version >= 6.7 before using Car API Level 3 2021-09-12 11:46:15 +02:00
johan12345
f6bb3c03ba Release 1.0.0-beta02
(just to fix a crash caused by Lottie)
2021-09-11 22:29:20 +02:00
johan12345
134f3856b9 upgrade Lottie 2021-09-11 22:23:30 +02:00
johan12345
4974cc6d83 Release 1.0.0-beta01 2021-09-11 11:53:13 +02:00
Johan von Forstner
edd072b83a Merge pull request #101 from johan12345/car-app-library-1.1.0
Update Android Auto to Car App Library 1.1.0, Adds vehicle data and Chargeprice integration
2021-09-11 11:43:27 +02:00
Johan von Forstner
35ddda5bfe Update screenshots in README 2021-09-11 11:16:09 +02:00
johan12345
8b241e3f6f new screenshots
- English and German
- Mapbox and Google Maps
- + Android Auto
2021-09-11 11:09:08 +02:00
johan12345
b3c5fe788d suppress lint error 2021-09-11 09:54:30 +02:00
johan12345
6fd737f6e9 Android Auto: Disable Chargeprice in unsupported countries
see also: cf6c6628, #117
2021-09-11 09:54:30 +02:00
johan12345
08cd4eb849 Android Auto: do not update the map when location changes
to avoid running into template restrictions
2021-09-11 09:54:30 +02:00
johan12345
ff75594b37 get CarHardwareManager lazily 2021-09-11 09:54:30 +02:00
johan12345
2576bc4854 upgrade compileSdk to Android 12
required for new car app library
2021-09-11 09:54:30 +02:00
johan12345
b2c29b647b upgrade car app library to 1.1.0-beta01 2021-09-11 09:54:30 +02:00
johan12345
2167a63321 only use ConstraintManager if car API level >= 2
refs a562ee6c
2021-09-11 09:54:30 +02:00
johan12345
fb0a2cfa1c internal test release 2021-09-11 09:54:30 +02:00
johan12345
07be77c573 ChargepriceScreen: fix showing error messages 2021-09-11 09:54:30 +02:00
johan12345
ae0a84db4c VehicleDataScreen: setup listeners with lifecycle events 2021-09-11 09:54:30 +02:00
johan12345
dc5ffb148d Chargeprice: check car API level 2021-09-11 09:54:30 +02:00
johan12345
066b7c085e add link to Chargeprice website 2021-09-11 09:54:30 +02:00
johan12345
4ae16df064 add Chargeprice icon 2021-09-11 09:54:30 +02:00
johan12345
17a40127e6 add Chargeprice to Android Auto
fixes #80
2021-09-11 09:54:30 +02:00
johan12345
31ad748796 use car hardware location data if available 2021-09-11 09:54:30 +02:00
johan12345
fe4db38798 show vehicle data screen only if API level available 2021-09-11 09:54:30 +02:00
johan12345
6c2243078b Vehicle data screen: Add speed and range + gauge icons 2021-09-11 09:54:30 +02:00
johan12345
71f1ee8d7b make VehicleDataScreen request permissions and work correctly 2021-09-11 09:54:30 +02:00
johan12345
ab0c37cb82 make PermissionScreen reusable 2021-09-11 09:54:30 +02:00
Johan von Forstner
65189cd798 Android Auto: create a VehicleDataScreen showing state of charge 2021-09-11 09:54:30 +02:00
Johan von Forstner
630178bfcf Update car app library to 1.1.0-alpha02 2021-09-11 09:54:30 +02:00
Johan von Forstner
bcee975124 remove now unneeded @ExperimentalCarApi annotations 2021-09-11 09:54:30 +02:00
Johan von Forstner
04fc17d73c increase image size corresponding to updated Android Auto docs 2021-09-11 09:54:30 +02:00
Johan von Forstner
139c02ef70 use ConstraintManager to dynamically get maximum number of items to be shown on Android Auto list 2021-09-11 09:54:30 +02:00
Johan von Forstner
88a8520f27 use CarAppService.requestPermission() instead of custom PermissionActivity 2021-09-11 09:54:30 +02:00
johan12345
4f3157a0ac update Car app library to 1.1.0-alpha1 2021-09-11 09:54:30 +02:00
johan12345
17d57729b3 remove Android Auto screenshots from F-Droid 2021-09-11 09:49:40 +02:00
johan12345
1f3df2e0bf update F-Droid description, removing features that are not available in the F-Droid version
(Google Maps data, Android Auto)
2021-09-11 09:49:09 +02:00
johan12345
e2e95ce85d fix NPE in AvailabilityDetector 2021-09-06 20:34:20 +02:00
johan12345
d79b554dcc remove unused nav graph destination 2021-09-06 20:18:25 +02:00
johan12345
98e91ea3db FavoritesFramgent: create locationClient in onCreate, not onCreateView 2021-09-05 17:02:45 +02:00
johan12345
b8c8245978 Android Auto: avoid unnecessary location updates 2021-09-05 16:47:22 +02:00
johan12345
fd1f05888a fix IndexOutOfBoundsException 2021-09-05 15:01:46 +02:00
johan12345
2e4167689d new 0.9.1 release 2021-09-01 19:35:09 +02:00
johan12345
8a2ad55dd6 Mapbox Autocomplete: set language 2021-09-01 19:33:31 +02:00
johan12345
44ce0cfaea Release 0.9.1 2021-09-01 19:23:27 +02:00
johan12345
70f964549e limit autocomplete entries to a single line 2021-09-01 19:14:23 +02:00
johan12345
c045eed41a Mapbox Autocomplete: handle cases where house number comes in front of address 2021-09-01 19:11:12 +02:00
johan12345
3ded108c3c fix exiting app intro after 7eeb10fa 2021-09-01 18:58:38 +02:00
johan12345
b3eb1e31e8 app intro data source selection: add missing fillViewport to ScrollView 2021-09-01 18:52:52 +02:00
johan12345
7eeb10faca app intro: add page about Android Auto support
fixes #114
2021-09-01 18:50:58 +02:00
johan12345
4208e1a4b5 rename some strings 2021-09-01 18:24:54 +02:00
johan12345
54004f14b5 fix missing SharedPreferences.apply() 2021-09-01 18:22:04 +02:00
johan12345
8eabff4888 fix crash on rotate in various fragments
caused by access to appBarConfiguration before it is initialized
fixes #121
2021-09-01 18:20:36 +02:00
johan12345
d5b5337aeb donation screen: limit width of description so that it does not overlap with the price 2021-08-29 16:55:36 +02:00
johan12345
913d8a00cf add dialog with info about open source project and donations
shown after a few launches of the app
#114
2021-08-29 16:52:44 +02:00
Johan von Forstner
fc5003cd31 Merge pull request #120 from johan12345/new-autocomplete-ui
New place search UI supporting both Mapbox and Google Places
2021-08-29 00:08:02 +02:00
johan12345
ff96e49ead use TooltipCompat (fixes crash on Android 7 and earlier) 2021-08-29 00:05:10 +02:00
johan12345
36bd74e091 adjust position of search bar 2021-08-28 23:49:32 +02:00
johan12345
3d0dc16f49 Google Places: fix API quota limit detection 2021-08-28 23:42:11 +02:00
johan12345
41b374350b display distance in autocomplete results 2021-08-28 23:31:00 +02:00
johan12345
fc72044b82 delay Mapbox autocomplete requests 2021-08-28 22:55:57 +02:00
johan12345
e96fcd4a88 make data provider for place search selectable in settings 2021-08-28 22:55:57 +02:00
johan12345
36f34bde1e build new custom autocomplete UI supporting both Google Maps and Mapbox 2021-08-28 22:55:57 +02:00
johan12345
624c5d8f92 Chargeprice: tariff name and provider name comparison should be case-independent 2021-08-26 09:32:32 +02:00
johan12345
7fcb187dda my tariffs preference: add summary 2021-08-26 09:29:13 +02:00
johan12345
7188a2aa64 add ACRA for crash reporting 2021-08-26 09:24:46 +02:00
johan12345
cf6c662832 Disable Chargeprice button for unsupported countries (fixes #117) 2021-08-24 08:59:55 +02:00
johan12345
4ceef7997d "About EVMap" screen: explain what "FAQ" means 2021-08-23 14:51:20 +02:00
johan12345
3f79bdd125 App intro: add info where to find the colors later 2021-08-23 14:48:48 +02:00
Johan von Forstner
fb279f90c5 Add sponsoring info 2021-08-21 16:41:53 +02:00
johan12345
6f35ced260 fix Chargeprice website link for OpenChargeMap 2021-08-15 18:31:44 +02:00
Johan von Forstner
c967bab524 Merge pull request #116 from johan12345/adaptive-layout
Adaptive layout for intro and other things
2021-08-15 12:25:47 +02:00
johan12345
6bf80e2b49 adjust detail view peekHeight for nonstandard font sizes 2021-08-14 17:54:58 +02:00
johan12345
d97cb4b9fb put data source selection into ScrollView 2021-08-14 17:33:34 +02:00
johan12345
17eaeb99da decrease intro animation size if necessary 2021-08-14 16:39:56 +02:00
johan12345
beebbe1c1b avoid animations overlapping text in intro 2021-08-14 16:35:24 +02:00
Johan von Forstner
0a2bbd5fb4 Fix typo in README 2021-08-10 19:42:43 +02:00
johan12345
7f1f4b67a1 Release 0.9.0 2021-08-09 19:07:55 +02:00
166 changed files with 4629 additions and 797 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
_img/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text
fastlane/metadata/android/**/images/**/*.png filter=lfs diff=lfs merge=lfs -text

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: johan12345
custom: 'https://paypal.me/johan98'

View File

@@ -19,7 +19,7 @@ Features
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using Chargeprice.app [Chargeprice.app](https://chargeprice.app) (only in Europe)
- 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
@@ -28,7 +28,7 @@ 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://media.githubusercontent.com/media/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://media.githubusercontent.com/media/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0731d286fe0dd41c068cba6b32b55c6560c2ce9e04f89837a91af4fb76c57861
size 198603

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63e95826a0206522c83ec61084715866076b19dd6d29812e7b50abb0ca248a58
size 95959

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:649ec77837aa0322583a83fbd675f62b771032aa3690a7de7bb5e4b4e97da31e
size 90238

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc3f077c912439554cd2e5bea86621985c50721aaa7ac445bc7d6cfb5b47bde8
size 46030

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11de773f36770cbd0249dee34f965d1b6568d53bb73cc8671824be2bc82b294e
size 248261

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:575eb7389a2334e579457ab3aa939ce300862d4df137384cd68b9d279915930a
size 91867

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db0d2ca1156283aa0ae81a958994fe6224d91d8002bf50fbf362bcd56efd56eb
size 90128

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0fe97239d8421babbf81a6828e37f154443c42472465c9e723f38eeff0cc2e
size 42451

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3c7ec5fad1b3c9ee419c4fd3f0f3595bfe20f8a169d96e7b1f1305bf3f1e51b
size 1111142

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2b82858437977781d4e33d7943a00f8bb42e5c2198a9d757346eb5bc1dc4aa4e
size 995603

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d9427caf64d0603f4ab24c90e7fb5469c3af880cb2eeeba2fc572ce5ca8aab7
size 360777

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f62adf4c68e939a8963f124c291ec7441ea05aa9b17835226a0a23555cd89ce
size 83183

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ca6b6505c2b4401dfbca5add2fe6e092e77d917cc6ca1b6264f7b764dd5ff8c
size 115657

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4fec0a34114d957c75fe02cb7e972a752c704b41a162fcd61018fb94b2f51499
size 895589

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d96236006f8a71429080ead33352b51cdc24dac997e13bb21cac9be33c88f01
size 857431

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f1561eeceaaf11dfc513b3e94fdb87e33363aca6afdff99ed9058bb2549ea59
size 348799

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94c75119d726f2926f788266e2bf1d2572079cdeb24a7721eec90b38d94b7d57
size 96031

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:498c57fade7895b6c714088c489d829d44da75d8f69b8ecab6c8f998f74411b5
size 137330

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:92778f3df0ba65e46ddc2ea79dff544d45eee3b029d01b519b55a1c29c2cfb6b
size 1096287

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f68bddaf0d4922934a6c28c8542c9ef4942930fadb0b2d4a70e5a5000b3a66a
size 995021

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bff00f4f84431c9e2d90a123abcc5b25938f188b47d46d07bc7015f622f794f4
size 351111

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bfb430a70fd66b2bf71dd48a2ce47419f9b2a269490235f062f6b6ec683bc47
size 85445

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7527bf765e8132223ca35021de4e4f5335fd220726d205da12d667fbd348772c
size 108988

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaa0032bd567bd9f6257bf17ca72915f973d676102c66d532d12309bc901cb5e
size 884287

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:170fc47f88b2515f0b7d1c9b8e87c3fb905324ec32f6e25937f2fc241fbe1bbb
size 857298

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaf780abbad2388002fc9afc9d6b1534061be9a6bab96bd3c166b3317d5332ff
size 338280

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5b58726094bb4f0d29229b729a3c369bf9f9b5ad7a9f355dbf20430b656e2ad
size 97536

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b12d10d0eef40fabc9221bdc45460c7fe607ff9b254e59ecf92123413f3f22c
size 124451

View File

@@ -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 53
versionName "0.9.0"
targetSdkVersion 31
versionCode 63
versionName "1.0.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
}
@@ -105,15 +108,16 @@ android {
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.3.1'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha01'
implementation "androidx.activity:activity-ktx:1.3.1"
implementation "androidx.fragment:fragment-ktx:1.3.6"
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 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.3.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
@@ -128,7 +132,7 @@ dependencies {
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,7 +140,8 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
googleImplementation 'androidx.car.app:app:1.0.0'
googleImplementation 'androidx.car.app:app:1.1.0-beta01'
googleImplementation 'androidx.car.app:app-projected:1.1.0-beta01'
// AnyMaps
def anyMapsVersion = '95ddd6c083'
@@ -157,6 +162,7 @@ dependencies {
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'
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
@@ -185,15 +191,21 @@ 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.1'
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"

View File

@@ -0,0 +1,5 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))

View File

@@ -27,14 +27,16 @@ class DonateFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
val navController = findNavController()
binding.toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
binding.btnDonate.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.paypal_link))
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,27 +1,28 @@
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.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 +39,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 +47,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) {
@@ -65,33 +68,49 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
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) {
updateLocation(loc.location.value)
locationService?.let { service ->
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
service.removeLocationUpdates()
cas.unbindService(serviceConnection)
locationService = null
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
if (!locationPermissionGranted()) return
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCarHardwareLocationReceived
)
}
cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
@@ -101,6 +120,9 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun unbindLocationService() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
}
locationService?.let { service ->
service.removeLocationUpdates()
cas.unbindService(serviceConnection)

View File

@@ -0,0 +1,263 @@
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.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.launch
import moe.banana.jsonapi2.HasOne
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
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 = 6
private var errorMessage: String? = null
private val batteryRange = listOf(20.0, 80.0)
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 = model?.name?.value
lifecycleScope.launch {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
return@launch
} else if (vehicles.size > 1) {
if (manufacturer != null && modelName != null) {
vehicles = vehicles.filter {
it.brand == manufacturer && it.name.startsWith(modelName)
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
return@launch
} else if (vehicles.size > 1) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
return@launch
}
} else {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
return@launch
}
}
val car = vehicles[0]
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
val result = api.getChargePrices(ChargepriceRequest().apply {
this.dataAdapter = dataAdapter
station = cpStation
vehicle = HasOne(car)
options = ChargepriceOptions(
batteryRange = batteryRange,
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()
}
}
private fun getDataAdapter(): String? = when (charger.dataSource) {
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
else -> null
}
}

View File

@@ -20,6 +20,7 @@ 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.nameForPlugType
import net.vonforst.evmap.api.stringProvider
@@ -48,7 +49,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
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 iconGen =
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
init {
referenceData.observe(this) {
@@ -147,29 +151,49 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
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()
}

View File

@@ -26,15 +26,11 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = CarIcon.Builder(
IconCompat.createWithBitmap(
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
)
)
).build()
emptyIcon = Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
).asCarIcon()
}
init {
@@ -72,7 +68,9 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
setTitle(it.name)
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
setTitle(name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {

View File

@@ -6,6 +6,7 @@ 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.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
@@ -39,12 +40,15 @@ 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
@@ -59,7 +63,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
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 {
@@ -214,6 +220,11 @@ 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
@@ -239,8 +250,8 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
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 {
@@ -263,7 +274,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
)
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(
referenceData,

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -1,7 +1,16 @@
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.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
@@ -17,4 +26,68 @@ 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
fun getDefaultDistanceUnit(): Int {
return when (Locale.getDefault().country) {
"US", "GB", "MM", "LR" -> CarUnit.MILE
else -> CarUnit.KILOMETER
}
}
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 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
}

View File

@@ -0,0 +1,215 @@
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} ${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
}
}

View File

@@ -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()
}

View File

@@ -0,0 +1,11 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import net.vonforst.evmap.storage.PreferenceDataSource
fun getAutocompleteProviders(context: Context) =
if (PreferenceDataSource(context).searchProvider == "google") {
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
} else {
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
}

View File

@@ -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.tasks.Tasks.await
import com.google.android.libraries.maps.model.LatLng
import com.google.android.libraries.maps.model.LatLngBounds
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)
)
)
}
}

View File

@@ -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
@@ -28,7 +27,7 @@ class DonateFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
@@ -36,14 +35,7 @@ class DonateFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
binding.productsList.apply {
adapter = DonationAdapter().apply {
@@ -65,4 +57,12 @@ class DonateFragment : Fragment() {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -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
}
}

View 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
)
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.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>

View File

@@ -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.MaterialComponents.Headline6"
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.MaterialComponents.Body2"
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.MaterialComponents.Button.OutlinedButton"
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>

View File

@@ -25,8 +25,9 @@
<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"
app:layout_constraintBottom_toBottomOf="parent"
@@ -34,7 +35,7 @@
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"

View File

@@ -4,6 +4,10 @@
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<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>
@@ -11,9 +15,22 @@
<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 (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?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>
</resources>

View File

@@ -8,7 +8,16 @@
<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="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>
@@ -16,9 +25,22 @@
<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 (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -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>
@@ -32,7 +33,8 @@
<activity
android:name=".MapsActivity"
android:label="@string/title_activity_maps"
android:theme="@style/AppTheme.LaunchScreen">
android:theme="@style/AppTheme.LaunchScreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -256,6 +258,16 @@
</intent-filter>
</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>

View 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);
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -4,12 +4,16 @@ 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
@@ -51,9 +55,8 @@ 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)
@@ -78,11 +81,27 @@ class MapsActivity : AppCompatActivity() {
}
prefs = PreferenceDataSource(this)
prefs.appStartCounter += 1
checkPlayServices(this)
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
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.startDestination = R.id.onboarding
navController.graph = navGraph
return

View File

@@ -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")
}

View File

@@ -0,0 +1,213 @@
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 { 500L }
}
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
if (results != null && results.count > 0) {
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint.toString()
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 (query.length < 3 || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList =
(recentPlaces.map { it.asAutocompletePlace(location.value) } +
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 { 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
}
}
}
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))

View File

@@ -100,7 +100,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

View File

@@ -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
}
}
}

View File

@@ -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")

View File

@@ -1,54 +0,0 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.content.Intent
import android.view.inputmethod.InputMethodManager
import androidx.core.os.ConfigurationCompat
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, location: LatLng?) {
val placeOptions = PlaceOptions.builder().apply {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
language(ConfigurationCompat.getLocales(fragment.resources.configuration)[0].language)
}.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())

View File

@@ -0,0 +1,186 @@
package net.vonforst.evmap.autocomplete
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
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
}
}
}
}
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?)

View File

@@ -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())

View File

@@ -1,7 +1,6 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -14,13 +13,12 @@ import net.vonforst.evmap.R
class AboutFragment : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
override fun onResume() {
super.onResume()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
@@ -59,6 +57,10 @@ class AboutFragment : PreferenceFragmentCompat() {
findNavController().navigate(R.id.action_about_to_donateFragment)
true
}
"github_sponsors" -> {
findNavController().navigate(R.id.action_about_to_github_sponsors)
true
}
"twitter" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.twitter_url))
true

View File

@@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
@@ -85,14 +84,6 @@ class ChargepriceFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
vm.charger.value = charger
@@ -161,11 +152,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 ->
@@ -217,6 +208,14 @@ class ChargepriceFragment : DialogFragment() {
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
companion object {
const val ARG_CHARGER = "charger"
const val ARG_DATASOURCE = "datasource"

View File

@@ -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()
)
}

View File

@@ -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
@@ -31,12 +27,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 +47,17 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
})
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_favorites, container, false
@@ -62,27 +65,20 @@ 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
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
adapter = FavoritesAdapter(onDelete = {
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,
MapFragment.showCharger(it.charger)
)
}
}
binding.favsList.apply {
@@ -97,17 +93,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 +112,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 +248,12 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -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
@@ -42,20 +41,7 @@ class FilterFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.filterProfile.observe(viewLifecycleOwner) {
if (it != null) {
toolbar.title = "${getString(R.string.menu_filter)}: ${it.name}"
}
}
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
binding.filtersList.apply {
adapter = FiltersAdapter()
@@ -68,7 +54,7 @@ class FilterFragment : Fragment() {
)
}
toolbar.setNavigationOnClickListener {
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
@@ -88,26 +74,52 @@ 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 ->
}
}
}
override fun onResume() {
super.onResume()
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)
}
}
}
}

View File

@@ -8,7 +8,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -57,15 +56,7 @@ class FilterProfilesFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
@@ -76,8 +67,8 @@ class FilterProfilesFragment : Fragment() {
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPos = viewHolder.adapterPosition;
val toPos = target.adapterPosition;
val fromPos = viewHolder.bindingAdapterPosition;
val toPos = target.bindingAdapterPosition;
val list = vm.filterProfiles.value?.toMutableList()
if (list != null) {
@@ -207,11 +198,19 @@ class FilterProfilesFragment : Fragment() {
touchHelper.attachToRecyclerView(binding.filterProfilesList)
toolbar.setNavigationOnClickListener {
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
fun delete(fp: FilterProfile) {
val position = vm.filterProfiles.value?.indexOf(fp) ?: return
// if there is already a profile to delete, delete it now

View File

@@ -1,16 +1,19 @@
package net.vonforst.evmap.fragment
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.location.Geocoder
import android.location.Location
import android.os.Bundle
import android.text.method.KeyListener
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.AdapterView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
@@ -20,8 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.MenuCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.*
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -68,9 +70,10 @@ import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -79,11 +82,13 @@ import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.utils.checkFineLocationPermission
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
import java.io.IOException
const val REQUEST_AUTOCOMPLETE = 2
const val ARG_CHARGER_ID = "chargerId"
const val ARG_LAT = "lat"
const val ARG_LON = "lon"
@@ -100,6 +105,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
private lateinit var prefs: PreferenceDataSource
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
@@ -119,6 +125,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return
}
if (binding.search.hasFocus()) {
removeSearchFocus()
}
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
@@ -133,6 +143,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PreferenceDataSource(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
@@ -149,7 +161,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.lifecycleOwner = this
binding.vm = vm
val provider = PreferenceDataSource(requireContext()).mapProvider
val provider = prefs.mapProvider
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
mapFragment!!.priority = arrayOf(
@@ -216,11 +228,24 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, vm.apiName)
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
setupObservers()
setupClickListeners()
setupAdapters()
(activity as? MapsActivity)?.setSupportActionBar(binding.toolbar)
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
try {
findNavController().navigate(R.id.action_map_to_opensource_donations)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
} catch (ignored: IllegalStateException) {
// "no current navigation node"
}
}
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
@@ -242,10 +267,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
vm.reloadPrefs()
if (requestingLocationUpdates && ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED && locationClient.isConnected
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
&& locationClient.isConnected
) {
requestLocationUpdates()
}
@@ -253,16 +276,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun setupClickListeners() {
binding.fabLocate.setOnClickListener {
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(ACCESS_FINE_LOCATION),
if (!requireContext().checkFineLocationPermission()) {
ActivityCompat.requestPermissions(
requireActivity(),
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
} else {
}
if (requireContext().checkAnyLocationPermission()) {
enableLocation(moveTo = true, animate = true)
}
}
@@ -296,9 +317,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
launchAutocomplete(this, vm.location.value)
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
}
@@ -335,6 +354,65 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
var searchKeyListener: KeyListener? = null
@SuppressLint("SetTextI18n")
private fun setupSearchAutocomplete() {
binding.search.threshold = 1
searchKeyListener = binding.search.keyListener
binding.search.keyListener = null
val adapter = PlaceAutocompleteAdapter(requireContext(), vm.location)
binding.search.setAdapter(adapter)
binding.search.onItemClickListener =
AdapterView.OnItemClickListener { _, _, position, _ ->
val place = adapter.getItem(position) ?: return@OnItemClickListener
lifecycleScope.launch {
try {
vm.searchResult.value = adapter.getDetails(place.id)
} catch (e: ApiUnavailableException) {
e.printStackTrace()
} catch (e: IOException) {
// TODO: show error
e.printStackTrace()
}
}
removeSearchFocus()
binding.search.setText(
if (place.secondaryText.isNotEmpty()) {
"${place.primaryText}, ${place.secondaryText}"
} else {
place.primaryText.toString()
}
)
}
binding.search.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
binding.search.keyListener = searchKeyListener
} else {
binding.search.keyListener = null
}
updateBackPressedCallback()
}
binding.clearSearch.setOnClickListener {
vm.searchResult.value = null
removeSearchFocus()
}
binding.toolbar.doOnLayout {
binding.search.dropDownWidth = binding.toolbar.width
binding.search.dropDownAnchor = R.id.toolbar
}
}
private fun removeSearchFocus() {
// clear focus and hide keyboard
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(binding.search.windowToken, 0)
binding.search.clearFocus()
}
private fun openLayersMenu() {
binding.fabLayers.tag = false
val materialTransform = MaterialContainerTransform().apply {
@@ -459,6 +537,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
@@ -483,6 +563,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
|| binding.search.hasFocus()
}
private fun unhighlightAllMarkers() {
@@ -800,11 +881,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
if (ContextCompat.checkSelfPermission(
requireContext(),
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
if (context?.checkAnyLocationPermission() ?: false) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -820,7 +897,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
}
@RequiresPermission(ACCESS_FINE_LOCATION)
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun enableLocation(moveTo: Boolean, animate: Boolean) {
val map = this.map ?: return
map.setMyLocationEnabled(true)
@@ -834,7 +911,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
if (location != null) {
@@ -944,7 +1021,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
) {
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
if ((grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED })) {
enableLocation(moveTo = true, animate = true)
}
}
@@ -1072,17 +1149,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_AUTOCOMPLETE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
vm.searchResult.value = handleAutocompleteResult(data)
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun getRootView(): View {
return binding.root
}
@@ -1128,11 +1194,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (ActivityCompat.checkSelfPermission(
context,
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
if (context.checkAnyLocationPermission()) {
moveToLastLocation(map, false)
requestLocationUpdates()
}

View File

@@ -79,7 +79,13 @@ class MultiSelectDialog : AppCompatDialogFragment() {
items = data.entries.toList()
.sortedBy { it.value.toLowerCase(Locale.getDefault()) }
.sortedByDescending { commonChoices?.contains(it.key) == true }
.sortedBy {
when {
selected.contains(it.key) -> 0
commonChoices?.contains(it.key) == true -> 1
else -> 2
}
}
.map { MultiSelectItem(it.key, it.value, it.key in selected) }
adapter.submitList(items)

View File

@@ -4,14 +4,15 @@ import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
@@ -20,6 +21,7 @@ import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
private lateinit var binding: FragmentOnboardingBinding
private lateinit var adapter: OnboardingViewPagerAdapter
override fun onCreateView(
inflater: LayoutInflater,
@@ -28,7 +30,7 @@ class OnboardingFragment : Fragment() {
): View {
binding = FragmentOnboardingBinding.inflate(inflater)
val adapter = OnboardingViewPagerAdapter(this)
adapter = OnboardingViewPagerAdapter(this)
binding.viewPager.adapter = adapter
binding.pageIndicatorView.count = adapter.itemCount
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
@@ -57,7 +59,7 @@ class OnboardingFragment : Fragment() {
}
fun goToNext() {
if (binding.viewPager.currentItem == 2) {
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
@@ -65,18 +67,6 @@ class OnboardingFragment : Fragment() {
}
}
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()
}
}
abstract class OnboardingPageFragment : Fragment() {
lateinit var parent: OnboardingFragment
@@ -105,12 +95,18 @@ class WelcomeFragment : OnboardingPageFragment() {
override fun onResume() {
super.onResume()
binding.animationView.playAnimation()
val drawable = (binding.animationView as ImageView).drawable
if (drawable is AnimatedVectorDrawable) {
drawable.start()
}
}
override fun onPause() {
super.onPause()
binding.animationView.progress = 0f
val drawable = (binding.animationView as ImageView).drawable
if (drawable is AnimatedVectorDrawable) {
drawable.stop()
}
}
}

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.fragment
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -57,7 +58,7 @@ class SettingsFragment : PreferenceFragmentCompat(),
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
myTariffsPreference.entries = tariffs.map {
if (!it.name.startsWith(it.provider)) {
if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
@@ -70,13 +71,18 @@ class SettingsFragment : PreferenceFragmentCompat(),
}
private fun updateMyTariffsSummary() {
myTariffsPreference.summary = if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(R.plurals.chargeprice_some_tariffs_selected, n, n)
}
myTariffsPreference.summary =
if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + getString(R.string.pref_my_tariffs_summary)
}
}
private fun updateMyVehiclesSummary() {
@@ -96,6 +102,12 @@ class SettingsFragment : PreferenceFragmentCompat(),
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
"search_delete_recent" -> {
Toast.makeText(context, R.string.deleted_recent_search_results, Toast.LENGTH_LONG)
.show()
vm.deleteRecentSearchResults()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -117,6 +129,12 @@ class SettingsFragment : PreferenceFragmentCompat(),
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}
"search_provider" -> {
if (prefs.searchProvider == "google") {
Toast.makeText(context, R.string.pref_search_provider_info, Toast.LENGTH_LONG)
.show()
}
}
}
}

View File

@@ -6,28 +6,39 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import net.vonforst.evmap.databinding.DialogUpdate060AndroidautoBinding
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.storage.PreferenceDataSource
class Update060AndroidAutoDialogFramgent : AppCompatDialogFragment() {
private lateinit var binding: DialogUpdate060AndroidautoBinding
class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogUpdate060AndroidautoBinding.inflate(inflater, container, false)
binding = DialogOpensourceDonationsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
PreferenceDataSource(requireContext()).update060AndroidAutoDialogShown = true
prefs.opensourceDonationsDialogShown = true
dismiss()
}
binding.btnDonate.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_donate)
}
binding.btnGithubSponsors.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
}
}
override fun onStart() {

View File

@@ -38,6 +38,9 @@ class CustomNavigator(
}
launchCustomTab(url)
}
if (destination.destination == "github_sponsors") {
launchCustomTab(context.getString(R.string.github_sponsors_link))
}
return null // Do not add to the back stack, managed by Chrome Custom Tabs
}

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.storage
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
@@ -23,19 +24,21 @@ import net.vonforst.evmap.model.*
MultipleChoiceFilterValue::class,
SliderFilterValue::class,
FilterProfile::class,
RecentAutocompletePlace::class,
GEPlug::class,
GENetwork::class,
GEChargeCard::class,
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class
], version = 13
], version = 14
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun filterProfileDao(): FilterProfileDao
abstract fun recentAutocompletePlaceDao(): RecentAutocompletePlaceDao
// GoingElectric API specific
abstract fun geReferenceDataDao(): GEReferenceDataDao
@@ -50,7 +53,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13
MIGRATION_12, MIGRATION_13, MIGRATION_14
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
@@ -256,6 +259,7 @@ abstract class AppDatabase : RoomDatabase() {
}
private val MIGRATION_13 = object : Migration(12, 13) {
@SuppressLint("Range")
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
@@ -301,5 +305,12 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `RecentAutocompletePlace` (`id` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `primaryText` TEXT NOT NULL, `secondaryText` TEXT NOT NULL, `latLng` TEXT NOT NULL, `viewport` TEXT, `types` TEXT NOT NULL, PRIMARY KEY(`id`, `dataSource`))");
}
}
}
}

View File

@@ -80,6 +80,12 @@ class PreferenceDataSource(val context: Context) {
context.getString(R.string.pref_map_provider_default)
)!!
val searchProvider: String
get() = sp.getString(
"search_provider",
context.getString(R.string.pref_search_provider_default)
)!!
var mapType: AnyMap.Type
get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString())
set(type) {
@@ -161,4 +167,17 @@ class PreferenceDataSource(val context: Context) {
.putFloat("chargeprice_battery_range_max", value[1])
.apply()
}
/** App start counter, introduced with Version 1.0.0 */
var appStartCounter: Long
get() = sp.getLong("app_start_counter", 0)
set(value) {
sp.edit().putLong("app_start_counter", value).apply()
}
var opensourceDonationsDialogShown: Boolean
get() = sp.getBoolean("opensource_donations_dialog_shown", false)
set(value) {
sp.edit().putBoolean("opensource_donations_dialog_shown", value).apply()
}
}

View File

@@ -0,0 +1,70 @@
package net.vonforst.evmap.storage
import androidx.room.*
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.autocomplete.AutocompletePlace
import net.vonforst.evmap.autocomplete.AutocompletePlaceType
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.utils.distanceBetween
import java.time.Instant
@Entity(primaryKeys = ["id", "dataSource"])
data class RecentAutocompletePlace(
val id: String,
val dataSource: String,
var timestamp: Instant,
val primaryText: String,
val secondaryText: String,
val latLng: LatLng,
val viewport: LatLngBounds?,
val types: List<AutocompletePlaceType>
) {
constructor(
place: AutocompletePlace,
details: PlaceWithBounds,
dataSource: String,
timestamp: Instant
) : this(
place.id, dataSource, timestamp, place.primaryText.toString(),
place.secondaryText.toString(), details.latLng, details.viewport, place.types
)
fun asAutocompletePlace(currentLocation: LatLng?): AutocompletePlace {
return AutocompletePlace(
primaryText,
secondaryText,
id,
currentLocation?.let {
distanceBetween(
latLng.latitude, latLng.longitude,
it.latitude, it.longitude
)
},
types + AutocompletePlaceType.RECENT
)
}
fun asPlaceWithBounds(): PlaceWithBounds {
return PlaceWithBounds(latLng, viewport)
}
}
@Dao
abstract class RecentAutocompletePlaceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg places: RecentAutocompletePlace)
@Query("DELETE FROM recentautocompleteplace")
abstract suspend fun deleteAll()
@Query("SELECT * FROM recentautocompleteplace WHERE dataSource = :dataSource AND primaryText LIKE '%' || :query || '%' ORDER BY timestamp DESC LIMIT :limit")
abstract fun search(
query: String,
dataSource: String,
limit: Int? = null
): List<RecentAutocompletePlace>
@Query("SELECT * FROM recentautocompleteplace WHERE dataSource = :dataSource ORDER BY timestamp DESC LIMIT :limit")
abstract fun getAll(dataSource: String, limit: Int? = null): List<RecentAutocompletePlace>
}

View File

@@ -1,11 +1,14 @@
package net.vonforst.evmap.storage
import androidx.room.TypeConverter
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import net.vonforst.evmap.api.goingelectric.GEChargerPhotoAdapter
import net.vonforst.evmap.api.openchargemap.OCMChargerPhotoAdapter
import net.vonforst.evmap.autocomplete.AutocompletePlaceType
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
@@ -41,6 +44,12 @@ class Converters {
val type = Types.newParameterizedType(List::class.java, String::class.java)
moshi.adapter<List<String>>(type)
}
private val latLngAdapter by lazy {
moshi.adapter<LatLng>(LatLng::class.java)
}
private val latLngBoundsAdapter by lazy {
moshi.adapter<LatLngBounds>(LatLngBounds::class.java)
}
@TypeConverter
fun fromChargepointList(value: List<Chargepoint>?): String {
@@ -115,4 +124,34 @@ class Converters {
fun toStringList(value: String): List<String>? {
return stringListAdapter.fromJson(value)
}
@TypeConverter
fun fromLatLng(value: LatLng?): String {
return latLngAdapter.toJson(value)
}
@TypeConverter
fun toLatLng(value: String): LatLng? {
return latLngAdapter.fromJson(value)
}
@TypeConverter
fun fromLatLngBounds(value: LatLngBounds?): String {
return latLngBoundsAdapter.toJson(value)
}
@TypeConverter
fun toLatLngBounds(value: String): LatLngBounds? {
return latLngBoundsAdapter.fromJson(value)
}
@TypeConverter
fun fromAutocompletePlaceTypeList(value: List<AutocompletePlaceType>): String {
return value.joinToString(",") { it.name }
}
@TypeConverter
fun toAutocompletePlaceTypeList(value: String): List<AutocompletePlaceType> {
return value.split(",").map { AutocompletePlaceType.valueOf(it) }
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
class AutocompleteTextViewWithSuggestions(ctx: Context, args: AttributeSet) :
androidx.appcompat.widget.AppCompatAutoCompleteTextView(ctx, args) {
override fun enoughToFilter(): Boolean = true
override fun onFocusChanged(
focused: Boolean, direction: Int,
previouslyFocusedRect: Rect?
) {
super.onFocusChanged(focused, direction, previouslyFocusedRect)
if (focused && adapter != null) {
performFiltering(text, 0)
}
}
}

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.text.HtmlCompat
@@ -22,6 +23,9 @@ import com.google.android.material.slider.RangeSlider
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import net.vonforst.evmap.kmPerMile
import net.vonforst.evmap.meterPerFt
import net.vonforst.evmap.shouldUseImperialUnits
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -77,16 +81,34 @@ fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) {
@BindingAdapter("isFabActive")
fun isFabActive(view: FloatingActionButton, isColored: Boolean) {
val color = view.context.theme.obtainStyledAttributes(
view.imageTintList = activeTint(view.context, isColored)
}
@BindingAdapter("backgroundTintActive")
fun backgroundTintActive(view: View, isColored: Boolean) {
view.backgroundTintList = activeTint(view.context, isColored)
}
@BindingAdapter("imageTintActive")
fun imageTintActive(view: ImageView, isColored: Boolean) {
view.imageTintList = activeTint(view.context, isColored)
}
private fun activeTint(
context: Context,
isColored: Boolean
): ColorStateList {
val color = context.theme.obtainStyledAttributes(
intArrayOf(
if (isColored) {
R.attr.colorAccent
R.attr.colorPrimary
} else {
R.attr.colorControlNormal
}
)
)
view.imageTintList = ColorStateList.valueOf(color.getColor(0, 0))
val valueOf = ColorStateList.valueOf(color.getColor(0, 0))
return valueOf
}
@BindingAdapter("data")
@@ -274,6 +296,26 @@ fun time(value: Int): String {
else "%d:%02d h".format(h, min);
}
fun distance(meters: Number?): String? {
if (meters == null) return null
if (shouldUseImperialUnits()) {
val ft = meters.toDouble() / meterPerFt
val mi = meters.toDouble() / 1e3 / kmPerMile
return when {
ft < 1000 -> "%.0f ft".format(ft)
mi < 10 -> "%.1f mi".format(mi)
else -> "%.0f mi".format(mi)
}
} else {
val km = meters.toDouble() / 1e3
return when {
km < 1 -> "%.0f m".format(meters.toDouble())
km < 10 -> "%.1f km".format(km)
else -> "%.0f km".format(km)
}
}
}
@InverseBindingAdapter(attribute = "app:values")
fun getRangeSliderValue(slider: RangeSlider) = slider.values
@@ -311,4 +353,9 @@ fun myTariffsBackground(view: View, myTariff: Boolean) {
view.background = it.getDrawable(0)
}
}
}
@BindingAdapter("tooltipTextCompat")
fun setTooltipTextCompat(view: View, text: String) {
TooltipCompat.setTooltipText(view, text)
}

View File

@@ -1,7 +1,11 @@
package net.vonforst.evmap.utils
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import androidx.core.content.ContextCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlin.math.*
@@ -75,3 +79,17 @@ fun boundingBox(pos: LatLng, sizeMeters: Double): LatLngBounds {
pos.plusMeters(sizeMeters, sizeMeters)
)
}
fun Context.checkAnyLocationPermission() = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
fun Context.checkFineLocationPermission() = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED

View File

@@ -49,27 +49,10 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
MutableLiveData<ChargepriceCar>()
}
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 vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
MediatorLiveData<List<String>>().apply {
addSource(vehicle) {
value = it?.dcChargePorts?.map {
plugMapping[it]
}?.filterNotNull()?.plus(acConnectors)
value = it?.compatibleEvmapConnectors
}
}
}
@@ -201,12 +184,16 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
} else if (cpMeta.status == Status.LOADING) {
value = Resource.loading(null)
} else {
value =
Resource.success(cpMeta.data!!.chargePoints.filter {
it.plug == getChargepricePlugType(
chargepoint
) && it.power == chargepoint.power
}[0])
val result = cpMeta.data!!.chargePoints.filter {
it.plug == getChargepricePlugType(
chargepoint
) && it.power == chargepoint.power
}.elementAtOrNull(0)
value = if (result != null) {
Resource.success(result)
} else {
Resource.error("matching chargepoint not found", null)
}
}
}
}
@@ -241,7 +228,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency
)
}, getChargepriceLanguage())
}, ChargepriceApi.getChargepriceLanguage())
val meta =
result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta
chargePrices.value = Resource.success(result)
@@ -268,13 +255,4 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
}
}
private fun getChargepriceLanguage(): String {
val locale = Locale.getDefault().language
return if (ChargepriceApi.supportedLanguages.contains(locale)) {
locale
} else {
"en"
}
}
}

View File

@@ -66,7 +66,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
)
}
})
}?.sortedBy { it.distance }

View File

@@ -18,6 +18,7 @@ import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
@@ -27,8 +28,6 @@ import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?)
internal fun getClusterDistance(zoom: Float): Int? {
return when (zoom) {
in 0.0..7.0 -> 100
@@ -165,7 +164,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
loc.longitude,
charger.coordinates.lat,
charger.coordinates.lng
) / 1000
)
} else null
}
addSource(chargerSparse, callback)

View File

@@ -8,11 +8,13 @@ import kotlinx.coroutines.launch
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.storage.AppDatabase
import java.io.IOException
class SettingsViewModel(application: Application, chargepriceApiKey: String) :
AndroidViewModel(application) {
private var api = ChargepriceApi.create(chargepriceApiKey)
private var db = AppDatabase.getInstance(application)
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
@@ -49,4 +51,10 @@ class SettingsViewModel(application: Application, chargepriceApiKey: String) :
}
}
}
fun deleteRecentSearchResults() {
viewModelScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
}
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#33FFFFFF" />
<size
android:height="24dp"
android:width="24dp" />
</shape>
</item>
</layer-list>

Some files were not shown because too many files have changed in this diff Show More