Compare commits

..

359 Commits
1.2.0 ... 1.4.2

Author SHA1 Message Date
johan12345
dcc03da237 Release 1.4.2 2022-11-18 22:27:27 +01:00
johan12345
295c00ea55 prefer to open URLs in custom tab, even if native app available
(such as EVMap itself)
2022-11-18 22:02:09 +01:00
johan12345
8d6756d57d Release 1.4.1 2022-11-13 15:16:15 +01:00
johan12345
71acd28b74 upgrade robolectric 2022-11-13 15:09:50 +01:00
johan12345
e79c1168ff update dependencies 2022-11-13 14:43:02 +01:00
johan12345
9833159fa8 update target SDK to 33 (Android 13) 2022-11-13 14:37:37 +01:00
johan12345
88ace5ba82 Android >= 12: Add link in preferences to enable opening links 2022-11-13 14:19:15 +01:00
johan12345
0ed82d15ff Add support for opening openchargemap.org links in EVMap 2022-11-13 14:14:08 +01:00
johan12345
0f525a6c48 Fix address format when street is not provided
fixes #258
2022-11-12 21:10:03 +01:00
johan12345
a91a5ce52e replace times symbol with escape sequence
refs #257
2022-11-12 20:58:25 +01:00
Maximilian Goldschmidt
cd3b1db90d Added multiple filter pages for Android Auto and AAOS (#251)
* Added multiple filter pages for auto and automotive

* use IMAGE_TYPE_ICON for icons

* implement different approach for multi-page layout using DummyReturnScreen

* revert unnecessary changes

* Added multiple filter pages for auto and automotive

* use IMAGE_TYPE_ICON for icons

* implement different approach for multi-page layout using DummyReturnScreen

* revert unnecessary changes

* reimplement EditFiltersScreen pagination to allow for arbitrary number of rows

* add @lxam97 to contributors list

* move delete button back to EditFilterScreen

* implement pagination for FilterScreen

* Replaced Next and Back with the goto page

* fixes for FilterScreen

* update strings

Co-authored-by: johan12345 <johan.forstner@gmail.com>
2022-11-11 17:25:36 +01:00
johan12345
6e3e34c642 add fronyx API to GH actions release pipeline 2022-11-09 18:34:07 +01:00
johan12345
8ce7f5cae2 Android Auto ChargerDetailScreen: show data even before availability and photo is loaded 2022-11-05 19:01:50 +01:00
johan12345
fae3bb2038 Chargeprice: show plans where the price is not available
fixes #255
2022-11-05 12:53:30 +01:00
johan12345
9490aa7110 donottranslate.xml: split up contributors list into multiple lines 2022-11-04 23:13:59 +01:00
Hosted Weblate
66a27d19f3 Translated using Weblate (French)
Currently translated at 97.0% (33 of 34 strings)

Co-authored-by: Altons <marsupilami450@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/fr/
Translation: EVMap/Android (strings specific to Google Play variant)
2022-11-04 23:08:30 +01:00
Hosted Weblate
09cf6cb087 Translated using Weblate (Norwegian Bokmål)
Currently translated at 82.3% (28 of 34 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/nb_NO/
Translation: EVMap/Android (strings specific to Google Play variant)
2022-11-04 23:08:29 +01:00
johan12345
4d23c916a9 fix repeated call of onCheckedChangeListener 2022-11-01 11:45:23 +01:00
johan12345
fec5de1de1 BarGraphView: don't crash if onDraw is called before onSizeChanged 2022-11-01 11:31:10 +01:00
johan12345
89957ef738 update CustomBottomSheetBehavior
fixes #247 (problem was that layout is not applied in settling state)
2022-10-31 22:38:22 +01:00
johan12345
a8e9bcd9eb improve bottomSheetExpanded LiveData 2022-10-31 22:24:13 +01:00
johan12345
0c3e3b0c35 Another constraint fix
Refs 1b7b5121e6, #253
2022-10-31 22:24:13 +01:00
johan12345
78f9b7162c Fix #252: Pins have wrong color after switching filter 2022-10-31 22:24:13 +01:00
johan12345
600a294ab2 Fix #252: Pins have wrong color after switching filter 2022-10-31 22:01:56 +01:00
johan12345
1b8bedcd6d improve switch between single- and multiline mode for charger name 2022-10-31 21:53:11 +01:00
johan12345
1b7b5121e6 rework constraints for name & icons at top of detail view
fixes #253
2022-10-31 21:43:11 +01:00
johan12345
e469ce83e5 Release 1.4.0 2022-10-30 17:46:29 +01:00
johan12345
ef68e6039e Auto: make navigate button primary action 2022-10-30 17:41:10 +01:00
johan12345
2ad673f8aa Automotive: Don't show "opened on phone" toast when we're in fact opening on the car's browser 2022-10-30 17:41:10 +01:00
johan12345
5b55087337 rework adapters in ChargepriceFragment to increase performance
fixes #222
2022-10-26 22:46:47 +02:00
Hosted Weblate
cb8a81823d Translated using Weblate (Norwegian Bokmål)
Currently translated at 85.1% (230 of 270 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2022-10-26 21:25:16 +02:00
johan12345
742950b62c Android Auto: fix infinite loading in case location cannot be determined 2022-10-19 07:50:53 +02:00
johan12345
9bb8825ab7 Android Auto: fix a case of infinite loading 2022-10-18 21:36:45 +02:00
johan12345
a0c41290cd Android Auto: add data source selection on first start 2022-10-17 23:27:19 +02:00
johan12345
85240f0145 add possibility to pass fronyx API key with env variables 2022-10-16 12:40:36 +02:00
johan12345
7cc50d7127 Upgrade jsonapi library, implement workaround for relationships 2022-10-15 22:26:31 +02:00
Johan von Forstner
3d0ebc0b85 Merge pull request #244 from johan12345/car-app-library-1.3.0-2
Android Auto/Automotive enhancements
2022-10-13 22:53:14 +02:00
johan12345
4e83e37d61 Android Auto: allow to search for chargers along the current driving direction
#143
2022-10-13 22:38:46 +02:00
johan12345
94030c010c Car App Library 1.3: show prices for other tariffs in disabled state 2022-10-13 21:43:02 +02:00
johan12345
6b287c4084 ChargepriceScreen: fix crash if car is not selected 2022-10-13 21:28:17 +02:00
johan12345
1f6fe04b7d Revert "Revert "upgrade car app library to 1.3.0-beta01""
This reverts commit 087178193b.
2022-10-13 21:07:32 +02:00
johan12345
91d5ce02e2 Revert "Revert "try to use plug icons in detail view""
This reverts commit 20ae25cf8a.
2022-10-13 21:07:32 +02:00
johan12345
22bd9ed9e8 Revert "Revert "use CarIconSpan in row texts""
This reverts commit 1d3e3417aa.
2022-10-13 21:07:31 +02:00
johan12345
19142e0b59 Revert "Revert "check car app API level before setting OnContentRefreshListener""
This reverts commit 09d6647ec0.
2022-10-13 21:07:31 +02:00
johan12345
06fe347c73 Revert "Revert "Android Auto: move search button from filter screen back to map""
This reverts commit f2d98f9d82.
2022-10-13 21:07:31 +02:00
johan12345
adead1ac3c Revert "Revert "Upgrade Car App library""
This reverts commit d7a644cb78.
2022-10-13 21:07:30 +02:00
Johan von Forstner
f722ae5d7a Implement tablet layout for charger detail view (#242)
* prototype tablet layout for detail view

#133

* layout fixes for tablet layout

* make bottom sheet non-collapsible in tablet layout

* Update BottomSheetBehavior library

* change minimum size for tablet layout

* don't hide FABs in tablet layout
2022-10-09 21:08:26 +02:00
johan12345
33a14c581c fix ListIndexOutOfBoundsException 2022-10-09 21:01:07 +02:00
johan12345
b80ddf8851 fix double divider line for chargers not supported by Chargeprice 2022-10-08 15:12:54 +02:00
johan12345
848745359d If connector filter is set, only show prediction for filtered connectors 2022-10-08 15:12:54 +02:00
johan12345
5d47ca2e3a add note that predictions are only for DC plugs
(in case it is relevant)
2022-10-08 15:12:54 +02:00
johan12345
c28a5382d4 add EVSEID support for NewMotionAvailabilityDetector 2022-10-08 15:12:54 +02:00
johan12345
f8bdae78cd add option to disable predictions 2022-10-08 15:12:54 +02:00
johan12345
9891cf8e88 Fronyx predictions: Only load data for supported chargers
(CCS and CHAdeMO located in Germany)
2022-10-08 15:12:54 +02:00
Johan von Forstner
172e66fe15 continue work on prediction UI 2022-10-08 15:12:54 +02:00
Johan von Forstner
4a925d10bd make the bar graph show utilization instead of availability 2022-10-08 15:12:54 +02:00
johan12345
c7fc0a34ed add "powered by fronyx" logo, work on UI 2022-10-08 15:12:54 +02:00
johan12345
9780f6d2c0 implement aggregation of prediction timeseries more elegantly 2022-10-08 15:12:54 +02:00
johan12345
f6afb2a8cb Add some labels to BarGraphView 2022-10-08 15:12:54 +02:00
johan12345
b0371c1b20 implement initial UI for predictions 2022-10-08 15:12:54 +02:00
johan12345
2625acffda Add EVSEID output to EnBwAvailabilityDetector 2022-10-08 15:12:54 +02:00
johan12345
7418748c0f Implement fronyx availability prediction API 2022-10-08 15:12:54 +02:00
Licaon_Kter
3c3edf9ab4 Changelogs mixup (#239)
* mixup locale

* and here
2022-10-07 14:02:02 +02:00
johan12345
d52ec0c63f improve heuristics in Chargeprice code
fixes #238
2022-10-05 21:28:51 +02:00
johan12345
eebb060f1a getChargepointDetail: emit loading state first 2022-10-05 20:52:27 +02:00
johan12345
19c0f311ad add stats to ChargepriceScreen 2022-10-04 19:03:06 +02:00
johan12345
9e280d3150 ChargepriceScreen: fix selection of correct chargepoint 2022-10-04 18:42:57 +02:00
johan12345
0998ed1f67 Release 1.3.14 2022-10-01 10:11:58 +02:00
johan12345
d7a644cb78 Revert "Upgrade Car App library"
This reverts commit 094a26980f.
2022-10-01 09:57:07 +02:00
johan12345
f2d98f9d82 Revert "Android Auto: move search button from filter screen back to map"
This reverts commit f24b7d1c2c.
2022-10-01 09:57:07 +02:00
johan12345
09d6647ec0 Revert "check car app API level before setting OnContentRefreshListener"
This reverts commit 4beb1f92ad.
2022-10-01 09:57:07 +02:00
johan12345
1d3e3417aa Revert "use CarIconSpan in row texts"
This reverts commit 4989aedd8b.
2022-10-01 09:57:07 +02:00
johan12345
20ae25cf8a Revert "try to use plug icons in detail view"
This reverts commit c8f333ce89.
2022-10-01 09:57:06 +02:00
johan12345
087178193b Revert "upgrade car app library to 1.3.0-beta01"
This reverts commit 09ded65b4e.
2022-10-01 09:57:06 +02:00
Johan von Forstner
60d54c989b update version code 2022-09-27 23:13:31 +02:00
Johan von Forstner
c0555e7965 Android Auto: show loading error as list text instead of toast 2022-09-27 23:11:18 +02:00
Johan von Forstner
49c2fb3494 Android Auto location fixes 2022-09-27 23:04:43 +02:00
Johan von Forstner
c1ec46917e add app_logo.svg 2022-09-27 19:33:33 +02:00
Johan von Forstner
ac11cddd42 Release 1.3.13 2022-09-27 19:02:05 +02:00
Johan von Forstner
6267e897d4 observe apiId liveData to fix bug 2022-09-27 18:59:38 +02:00
Johan von Forstner
8a0224707b Release 1.3.12 2022-09-26 16:49:16 +02:00
johan12345
09ded65b4e upgrade car app library to 1.3.0-beta01 2022-09-25 20:10:53 +02:00
johan12345
c8f333ce89 try to use plug icons in detail view
#199, #198
2022-09-25 20:10:53 +02:00
johan12345
4989aedd8b use CarIconSpan in row texts
fixes #199
2022-09-25 20:10:53 +02:00
johan12345
4beb1f92ad check car app API level before setting OnContentRefreshListener 2022-09-25 20:10:52 +02:00
johan12345
f24b7d1c2c Android Auto: move search button from filter screen back to map
This reverts commit 6b6c7da081.
2022-09-25 20:10:52 +02:00
johan12345
094a26980f Upgrade Car App library
This reverts commit 2e8cdb01fd.
2022-09-25 20:10:52 +02:00
Johan von Forstner
098f815ac9 fix german string 2022-09-25 20:10:40 +02:00
johan12345
6c51693b8d rebuild availability loading with switchMap 2022-09-21 20:15:47 +02:00
johan12345
a360c71ecb Upgrade dependencies 2022-09-20 19:54:51 +02:00
johan12345
b9da72e449 add supported countries for Chargeprice
fixes #234
2022-09-20 19:24:33 +02:00
johan12345
b0aeb8af98 Fix crash when changing data source
fixes #232
2022-09-20 19:15:45 +02:00
johan12345
1867d1bf7a Update source button text when API changes
fixes #233
2022-09-18 19:54:19 +02:00
johan12345
31fcee97e1 increase version code 2022-09-14 08:37:34 +02:00
johan12345
de8fd364f4 Android Auto: show loading errors 2022-09-14 08:36:51 +02:00
johan12345
e3f271be5d increase version code 2022-09-12 23:06:40 +02:00
johan12345
99a2540398 MapScreen: move away from LiveData to avoid more refresh bugs 2022-09-12 23:06:08 +02:00
johan12345
85173b438b increase version code 2022-09-12 22:43:42 +02:00
johan12345
c288883572 Android Auto: fix switch between filter settings 2022-09-12 22:42:34 +02:00
johan12345
c8f949da01 increase version code 2022-09-12 08:20:37 +02:00
johan12345
fe33dca1bc Android Auto: fix loading data on first start 2022-09-12 08:19:00 +02:00
johan12345
4fb5090e9b Release 1.3.11 2022-09-11 20:19:21 +02:00
johan12345
d9b8bf382a ChargerDetailScreen: make images square 2022-09-11 19:41:27 +02:00
johan12345
d69456dfd0 fix more issues switching the data source, esp. on Android Auto 2022-09-11 19:14:12 +02:00
johan12345
4da2a273c7 MapScreen: avoid updating chargers twice, which can lead to crashes
due to template update limit
2022-09-11 18:49:28 +02:00
johan12345
8e622c881d Android Auto: limit maxRows in MapScreen to at most 25 2022-09-11 18:38:04 +02:00
johan12345
89b2175d89 fix crashes due to race conditions when changing data source 2022-09-11 18:07:08 +02:00
johan12345
3c30481821 add ChargeLocationRepository
encapsulates logic to load charging stations for future implementation of offline caching (#164, #97)
2022-09-10 19:46:14 +02:00
johan12345
385353689d French: ignore ImpliedQuantity 2022-09-04 15:46:26 +02:00
johan12345
7f9c838b9d remove ignore:MissingQuantity for French 2022-09-04 15:41:30 +02:00
Hosted Weblate
205814e6f6 Translated using Weblate (Norwegian Bokmål)
Currently translated at 85.4% (223 of 261 strings)

Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2022-09-04 15:40:02 +02:00
Hosted Weblate
f30ae4a720 Translated using Weblate (French)
Currently translated at 100.0% (261 of 261 strings)

Co-authored-by: Altons <marsupilami450@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2022-09-04 15:40:02 +02:00
johan12345
a6117d3484 string adjustments 2022-09-04 11:05:50 +02:00
Hosted Weblate
f650def803 Translated using Weblate (Norwegian Bokmål)
Currently translated at 84.6% (221 of 261 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2022-09-04 11:04:33 +02:00
johan12345
09ec0d1635 fix size of marker icon (mainly in intro)
(regression from b82f6f68fb)
2022-09-04 08:43:39 +02:00
Hosted Weblate
cfc98209a1 Translated using Weblate (German)
Currently translated at 100.0% (261 of 261 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2022-09-03 20:24:44 +02:00
johan12345
17dbab4659 remove unnecessary period 2022-09-03 20:19:56 +02:00
johan12345
f4ed7f7397 remove French and Norwegian strings according to simplifications in English strings 2022-09-03 20:10:56 +02:00
johan12345
c26253fe44 adapt some German strings according to changes in English strings 2022-09-03 20:10:56 +02:00
johan12345
1c7cc32427 Further adjustments to @comradekingu's suggestions 2022-09-03 20:10:56 +02:00
Johan von Forstner
4ff944c4e4 Update app/src/main/res/values/strings.xml
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
2022-09-03 20:10:56 +02:00
Allan Nordhøy
2ec437a14b Suggestions, fixes, removals 2022-09-03 20:10:56 +02:00
Allan Nordhøy
ae05d44649 App strings reworked 2022-09-03 20:10:47 +02:00
johan12345
a0f90a8c94 restructure FilterValueDao.getFilterValues
possible fix for #225
2022-09-03 18:14:44 +02:00
johan12345
e1b7463490 rework locale chooser to use Android 13 native app locales 2022-09-03 15:37:58 +02:00
johan12345
27cbb5e208 add nautilusx to contributors 2022-09-01 20:57:03 +02:00
Hosted Weblate
5526e5d97c Translated using Weblate (Norwegian Bokmål)
Currently translated at 79.5% (210 of 264 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translation: EVMap/Android
2022-09-01 20:55:53 +02:00
Hosted Weblate
fc92ee9cdc Translated using Weblate (German)
Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: nautilusx <translate@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/de/
Translation: EVMap/Android
2022-09-01 20:55:52 +02:00
Hosted Weblate
f20fab965c Translated using Weblate (French)
Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Altons <marsupilami450@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2022-09-01 20:55:52 +02:00
johan12345
9146e7dab8 Merge remote-tracking branch 'weblate/master' 2022-08-25 20:43:12 +02:00
johan12345
54936b6e1a make MissingTranslation lint check a warning 2022-08-25 20:32:31 +02:00
Altons
f3245dc29b Translated using Weblate (French)
Currently translated at 100.0% (264 of 264 strings)

Translation: EVMap/Android
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
2022-08-25 20:27:25 +02:00
johan12345
90968029ad Release 1.3.10 2022-08-25 20:26:44 +02:00
Hosted Weblate
97cab1d007 Translated using Weblate (French)
Currently translated at 99.6% (263 of 264 strings)

Co-authored-by: Altons <marsupilami450@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translation: EVMap/Android
2022-08-25 20:24:19 +02:00
johan12345
0d41fb2685 Release 1.3.10 2022-08-25 20:23:53 +02:00
johan12345
2c0a5085ab Add contributors dialog in About
fixes #223
2022-08-25 20:22:30 +02:00
johan12345
9e80270a78 fix DataSourceSelectDialog OK button not working 2022-08-25 19:56:04 +02:00
johan12345
1b374cda1c Release 1.3.9 2022-08-24 22:29:10 +02:00
johan12345
b82f6f68fb Enhance marker color contrast
fix #220
2022-08-24 22:12:29 +02:00
johan12345
8e19399aaa Make border of mini markers more prominent (#220) 2022-08-24 21:41:35 +02:00
johan12345
e315da926e Fix selection of Norwegian locale in language chooser 2022-08-24 21:21:57 +02:00
johan12345
9450230856 minor fixes to translations
- make language names untranslatable
- missing plurals and strings
- add Norwegian to language selection
2022-08-24 21:14:28 +02:00
Johan von Forstner
81d62860e2 Merge pull request #211 from weblate/weblate-evmap-android
Translations update from Hosted Weblate
2022-08-24 20:45:58 +02:00
johan12345
e825654b9c suppress lint MissingQuantity for French
temporary until Weblate 4.14 with new plural requirements data is released
2022-08-24 20:45:41 +02:00
Hosted Weblate
0a4878a129 Translated using Weblate (Norwegian Bokmål)
Currently translated at 77.3% (205 of 265 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 75.7% (200 of 264 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Norwegian Bokmål)

Currently translated at 77.6% (205 of 264 strings)

Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Norwegian Bokmål)

Currently translated at 73.4% (194 of 264 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 81.2% (26 of 32 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 34.4% (91 of 264 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 34.4% (91 of 264 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 71.8% (23 of 32 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 32.9% (87 of 264 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (3 of 3 strings)

Added translation using Weblate (Norwegian Bokmål)

Added translation using Weblate (Norwegian Bokmål)

Added translation using Weblate (Norwegian Bokmål)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-automotive/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-fdroid/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/evmap/app-store-metadata/nb_NO/
Translation: EVMap/Android
Translation: EVMap/Android (strings specific to F-Droid variant)
Translation: EVMap/Android (strings specific to Google Play variant)
Translation: EVMap/Android (strings specific to the Android Automotive OS app)
Translation: EVMap/App Store metadata
2022-08-24 20:41:14 +02:00
johan12345
af50a95abd update AnyMaps
might reduce "white flash" in dark mode (at least for Google Maps) #216
2022-08-24 20:40:50 +02:00
johan12345
fe58551de9 fix highlighting of my charging plans
caused by problem with markomilos.jsonapi library (155aca0041)
fixes #221
2022-08-24 09:27:19 +02:00
johan12345
abc85c136b add French to manual language selection 2022-08-23 00:19:19 +02:00
Hosted Weblate
0ad4691d30 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/
Translation: EVMap/Android
2022-08-23 00:13:30 +02:00
Hosted Weblate
d85a64ec77 Translated using Weblate (French)
Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (French)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (French)

Currently translated at 99.6% (263 of 264 strings)

Translated using Weblate (French)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (French)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (French)

Currently translated at 100.0% (32 of 32 strings)

Translated using Weblate (French)

Currently translated at 96.5% (255 of 264 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3 of 3 strings)

Translated using Weblate (French)

Currently translated at 5.6% (15 of 264 strings)

Translated using Weblate (French)

Currently translated at 0.3% (1 of 264 strings)

Added translation using Weblate (French)

Added translation using Weblate (French)

Added translation using Weblate (French)

Added translation using Weblate (French)

Co-authored-by: Altons <marsupilami450@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johan von Forstner <johan.forstner@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android-automotive/fr/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-fdroid/fr/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/fr/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/
Translate-URL: https://hosted.weblate.org/projects/evmap/android/fr/
Translate-URL: https://hosted.weblate.org/projects/evmap/app-store-metadata/fr/
Translation: EVMap/Android
Translation: EVMap/Android (strings specific to F-Droid variant)
Translation: EVMap/Android (strings specific to Google Play variant)
Translation: EVMap/Android (strings specific to the Android Automotive OS app)
Translation: EVMap/App Store metadata
2022-08-23 00:11:58 +02:00
johan12345
52fefb564a eliminate duplicate strings for Google Maps and Mapbox 2022-08-22 23:25:34 +02:00
johan12345
d18b2e26b8 Separate "free" and "paid" strings for charging and parking
because... French :D
2022-08-22 23:22:16 +02:00
johan12345
d5f55366a9 Android Auto: Avoid possible extra template refresh due to race condition 2022-08-22 23:03:08 +02:00
johan12345
2f93e92b57 Android Auto: Use more appropriate title for place search data source selection screen 2022-08-22 22:47:49 +02:00
johan12345
24e5d072d6 GoingElectric: swap order of "categories" and "exclude faults" filters
Categories is not very useful as many chargers have no category assigned. Also, Polestar/Volvo AAOS only shows maximum 8 list items.
2022-08-22 22:37:15 +02:00
johan12345
a9e9055671 Release 1.3.8 2022-08-21 20:36:39 +02:00
johan12345
53ab8dc4e8 Add singular and plural variants for prefs_my_tariffs_summary 2022-08-21 20:18:21 +02:00
johan12345
c0d7d59817 work around double call of onViewCreated in MaterialDialogFragment 2022-08-21 18:50:23 +02:00
johan12345
42cfdfee1d add permission annotations 2022-08-20 21:54:23 +02:00
johan12345
41cb6cf6b0 Implement our own new fused location provider for Android API < 31
should address #214
regression since b445be99bb
2022-08-20 21:46:28 +02:00
johan12345
64f50cc5e6 Fix typo in fragment class name 2022-08-20 20:03:19 +02:00
johan12345
c24c03bb32 Fix crash in OpensourceDonationsDialogFramgent
caused by 17bd7f024e
2022-08-20 20:02:53 +02:00
johan12345
bec25dd4d2 update CustomBottomSheetBehavior
fixes detailAppBar disappearing after recreation (as mentioned by @PulsarFX in #213)
2022-08-20 19:44:05 +02:00
johan12345
4e4c5a0e9a keep map position after recreation even if there is a current search result
fixes #213
2022-08-20 19:27:50 +02:00
johan12345
17bd7f024e use Material styling for all dialogs 2022-08-20 18:40:48 +02:00
johan12345
fc5e77b01a Rework EditTextDialog
- use MaterialAlertDialog for modern styling
- use proper way to move dialog above keyboard
- fixes #212 (buttons were not clickable)
2022-08-20 17:33:49 +02:00
johan12345
6a114fc2ea Metadata: update title to fit within 30 characters
(as already done in Play Store)
2022-08-19 22:37:29 +02:00
johan12345
6e32c6644c docs/api_keys.md: Update documentation regarding Chargeprice API pricing 2022-08-18 22:32:57 +02:00
johan12345
8fb34ae66f Add information about Weblate translation to README
#208
2022-08-18 22:19:26 +02:00
johan12345
ae3489621e fix untranslated string 2022-08-18 21:56:06 +02:00
johan12345
ff0a110f51 restructure strings for compatibility with Weblate
#208
2022-08-18 21:45:03 +02:00
johan12345
24ef4888a8 restructure strings for compatibility with Weblate
#208
2022-08-18 21:07:09 +02:00
johan12345
13916b0c8d Allow moving map when filter menu is open
workaround using reflection
fixes #155
2022-08-18 20:33:50 +02:00
johan12345
efdd0d6bc5 Don't hide status bar in gallery fullscreen view
easy workaround to fix #193
2022-08-18 19:54:44 +02:00
johan12345
155aca0041 ChargepriceApi: replace moe.banana:moshi-jsonapi with com.markomilos.jsonapi 2022-08-17 17:33:44 +02:00
johan12345
6393eadc81 increase version code
(previous Play Store release was not accepted)
2022-08-16 22:02:24 +02:00
johan12345
4319ece4f3 ChargepriceFragment: avoid reloading prices on orientation change
using SavedStateHandle
2022-08-16 21:20:03 +02:00
johan12345
62f2002e5c Reload Chargeprice data when vehicles have changed
fixes #209
2022-08-16 21:04:44 +02:00
johan12345
4a82250a3d Release 1.3.7 2022-08-15 21:37:43 +02:00
johan12345
a8f23e9fb6 Android Auto/Automotive: Refresh data after relaunching app from background
fixes #207
2022-08-15 21:25:20 +02:00
johan12345
7da64fd566 after first start, remove onboarding from back stack
fixes #202
2022-08-15 20:56:28 +02:00
johan12345
09b5d536cb keep ChargerDetails in saved state
fixes #205
2022-08-15 20:53:22 +02:00
johan12345
5e01200d96 Chargeprice: remove errorneous tint from provider logo in dark mode
fixes #206
2022-08-15 18:54:05 +02:00
johan12345
c8d2e73218 BindingAdapters.colorToTransparent(): fix when values become negative
fixes #206
2022-08-15 18:52:13 +02:00
johan12345
d7b377ea56 disable mini markers completely when filtered by HPCs 2022-08-15 18:38:18 +02:00
johan12345
edd35fba1b suppress lint again 2022-08-13 16:29:18 +02:00
johan12345
1f23080141 suppress lint 2022-08-13 16:17:29 +02:00
johan12345
a3d9ecf49e fix another lint warning 2022-08-13 15:01:39 +02:00
johan12345
6681d3cc17 Android Auto: don't exit app when canceling vehicle data permission 2022-08-13 14:59:21 +02:00
johan12345
a184b817bc Android Auto: fix alignment of +/- buttons for Chargeprice range selection 2022-08-13 14:43:21 +02:00
johan12345
b658e0183c fix lint error 2022-08-13 14:38:11 +02:00
johan12345
6a0234ac2f use proper app icon for persistent Android Auto notification
fixes #201
2022-08-13 14:15:19 +02:00
johan12345
d5ac35100b use new Activity Result API for requesting permissions 2022-08-13 13:08:55 +02:00
johan12345
d3b4cb6a90 update AnyMaps 2022-08-13 13:08:55 +02:00
johan12345
5d70d8c09a replace deprecated override in NavHostFragment 2022-08-13 13:08:55 +02:00
johan12345
9642a58206 implement new MenuProvider API
to avoid deprecated functions
2022-08-13 13:08:55 +02:00
johan12345
0e3280a119 upgrade Kotlin to 1.7.10 2022-08-13 13:08:55 +02:00
johan12345
c60043f925 disable AndroidX Jetifier
which is now not needed anymore
2022-08-13 13:08:55 +02:00
johan12345
b445be99bb remove dependency on unmaintained Lost library
use Android's own Location APIs instead
2022-08-13 13:08:55 +02:00
johan12345
02395dda7f upgrade libraries 2022-08-13 13:08:55 +02:00
johan12345
c33c69db0b update Android Gradle Plugin 2022-08-13 13:08:55 +02:00
Johan von Forstner
77fdfc7ccb Update comparison of product flavors in README 2022-08-10 18:20:45 +02:00
johan12345
bbb5c93132 Release 1.3.6 2022-08-10 17:51:33 +02:00
johan12345
2e8cdb01fd Revert upgrade of Car App library 2022-08-10 17:35:48 +02:00
johan12345
6b6c7da081 Revert "Android Auto: move search button from filter screen back to map"
This reverts commit 9f0c5caf31.
2022-08-10 17:34:42 +02:00
johan12345
720d52285d Android Auto: use locationManager.getBestProvider 2022-08-07 22:07:59 +02:00
johan12345
e7efda2e90 Android Auto: request fine location permission 2022-08-07 21:29:24 +02:00
johan12345
ed80d7b968 Replace mapbox-events-android, removing compile-time GMS dependency
patched version at https://github.com/johan12345/mapbox-events-android
2022-08-07 19:52:45 +02:00
johan12345
8b1b971fad use PASSIVE_PROVIDER to get last known location faster 2022-08-07 13:51:18 +02:00
johan12345
cf20ab8d82 use LocationListenerCompat to fix crash on Android API < 30 2022-08-07 13:31:41 +02:00
johan12345
581d0c07ec increase version code 2022-08-06 16:47:19 +02:00
johan12345
0b17821611 AA: avoid crash when place search cannot load results 2022-08-06 16:46:33 +02:00
johan12345
2493328715 update AnyMaps
fixes crash due to deprecated setRetainInstance(true)
2022-08-06 16:35:54 +02:00
johan12345
f8abeed96c let Android Studio update gradle-wrapper.properties 2022-08-06 15:34:12 +02:00
johan12345
d9ca21c31e update gradle wrapper JAR 2022-08-06 10:17:55 +02:00
johan12345
f6998382b1 Hotfix Release 1.3.5 2022-08-06 09:58:35 +02:00
johan12345
5fc343d973 workaround infinite loop in onApplyWindowInsets when using Mapbox 2022-08-06 09:56:11 +02:00
johan12345
6b0a8bb506 new 1.3.4 release 2022-08-05 22:24:07 +02:00
johan12345
93f379f4e2 fix crash due to view not found 2022-08-05 22:22:28 +02:00
johan12345
00e555594a upgrade libraries 2022-08-05 22:22:05 +02:00
johan12345
4ec5c8fb2e fix highlighting of "my tariffs" in dark mode 2022-08-05 22:05:26 +02:00
johan12345
40b7ad8ef9 Android Auto: fix crash loading availabilities 2022-08-05 22:00:27 +02:00
johan12345
e1fed1ba26 Android Auto: fix reloading availabilities 2022-08-05 21:52:06 +02:00
johan12345
d429ef88b3 Release 1.3.4 2022-08-05 19:05:54 +02:00
johan12345
9f0c5caf31 Android Auto: move search button from filter screen back to map 2022-08-05 18:47:41 +02:00
johan12345
34b51a0742 Android Auto: update image size to follow new docs 2022-08-05 18:34:45 +02:00
johan12345
a533fd315e update libraries 2022-08-05 18:32:46 +02:00
johan12345
d39d51d32c Android Auto: reduce length of slider to avoid cutoff on small screens 2022-08-05 18:12:48 +02:00
johan12345
db11170967 fix rare NPE 2022-07-28 19:53:09 +02:00
johan12345
4135740d07 rework window insets handling
may fix issues with app logo in drawer & compass button on map
2022-07-24 13:34:23 +02:00
johan12345
b67bd12784 increase Gradle heap size 2022-07-24 12:45:55 +02:00
johan12345
b0e000e936 Android Auto: clear availabilities when content refresh is requested 2022-07-23 19:59:27 +02:00
johan12345
1d8a7347c9 TextPromptScreen: add OK and Cancel buttons
fixes #190
2022-07-23 18:24:01 +02:00
johan12345
90f6cb65a8 MapScreen: fix onItemVisibilityChanged if indices are -1 2022-07-23 18:14:37 +02:00
johan12345
5c57a5318b upgrade Android Gradle Plugin 2022-07-23 16:54:30 +02:00
johan12345
9456a6e8ef remove usages of deprecated @OnLifecycleEvent annotation 2022-07-23 16:52:24 +02:00
johan12345
4846699f66 update Google Maps library to 18.1.0 2022-07-23 16:43:34 +02:00
Johan von Forstner
682f05b98b exclude GMS dependency from Mapbox 2022-07-15 12:08:07 +02:00
Johan von Forstner
1f36ef6af8 use Google Places library only in google flavor
#197
2022-07-14 10:49:06 +02:00
Johan von Forstner
032be00bcd add donation hint for users who use Chargeprice data very often 2022-07-13 12:29:12 +02:00
Johan von Forstner
3ac7b4aaee Fix filtering availability by min power
Should be >= instead of >
2022-07-10 21:11:10 +02:00
Johan von Forstner
3386024acb Chargeprice: go directly to chargeprice settings to select vehicle 2022-07-10 20:06:36 +02:00
Johan von Forstner
ad2fb3063c Chargeprice: fix average charge speed
Now calculated as energy / duration

Fixes #171
2022-07-10 20:03:44 +02:00
johan12345
caee3b1d67 update favorite data when opening favorite detail view from list 2022-07-03 00:11:30 +02:00
johan12345
60b151c690 fix markers sometimes not being highlighted even though they should be 2022-07-02 23:55:16 +02:00
johan12345
e8873fa98c fix #177: After opening favorites list using shortcut, going back to map is not possible 2022-07-02 23:50:36 +02:00
johan12345
63740a8fe5 Android Auto/Automotive: Add place search
fixes #186
2022-07-02 16:12:09 +02:00
johan12345
c80452a1fd Android Auto: move delete button to filter profile details 2022-07-02 13:55:49 +02:00
johan12345
7420101153 Android Automotive OS: add driving direction to vehicle data
fixes #188
2022-07-02 13:47:45 +02:00
johan12345
080d3d1080 add simple test for car app 2022-06-29 20:40:17 +02:00
johan12345
d5ea8cfffa increase version code 2022-06-29 20:08:56 +02:00
johan12345
0676dcf31b Android Auto: fix requesting location permissions 2022-06-29 20:03:21 +02:00
johan12345
0aef554395 Release 1.3.3 2022-06-26 21:28:58 +02:00
johan12345
35f5185893 rebuild ChargecloudAvailabilityDetector and implement status for RheinEnergie 2022-06-26 21:01:58 +02:00
johan12345
f8378eb338 update only when map is idle
fixes error introduced in aa5c36d
2022-06-26 17:49:09 +02:00
Johan von Forstner
0bf56701cc Merge pull request #176 from johan12345/mini-markers
new "mini" marker variant to avoid clustering for zoom levels 11-13
2022-06-26 17:46:14 +02:00
johan12345
aa5c36d1aa new "mini" marker variant to avoid clustering for zoom levels 11-13 2022-06-26 17:44:35 +02:00
johan12345
93787fae74 set marker Z indices explicitly 2022-06-26 15:28:11 +02:00
johan12345
65b6c817fa IconGenerators: calculate precise image size to avoid unnecessary oversizing 2022-06-26 15:27:21 +02:00
johan12345
f022823093 Android Auto: implement deletion of filter profiles
fixes #172
2022-06-25 18:19:45 +02:00
johan12345
63bb161e09 Android Auto: implement slider filters
#172
2022-06-25 18:19:31 +02:00
johan12345
d0de607222 Android Auto: remove WelcomeScreen, default to MapScreen
fixes #179
2022-06-24 21:44:46 +02:00
johan12345
abec208768 Android Auto: start implementing creation of filter profiles
#172
2022-06-24 19:49:39 +02:00
johan12345
fa2b7bf180 remove extra logging from EnBwAvailabilityDetector 2022-06-23 19:34:49 +02:00
johan12345
258a04b14e SearchSelectScreen, FilterScreen: use nicer checkbox/radio button icons 2022-06-22 22:49:22 +02:00
johan12345
1cedb2bccd Android Auto/Automotive: add summary to my charging plans preference 2022-06-22 22:38:52 +02:00
johan12345
20409343fd Android Auto/Automotive: add "select all" option to tariffs selection screen
fixes #183
2022-06-22 22:35:37 +02:00
johan12345
24720d7670 fix lint errors 2022-06-22 22:32:01 +02:00
johan12345
096ef902b7 fix charging plans selection in Android Auto/Automotive
fixes #182
2022-06-22 22:23:56 +02:00
johan12345
e70ab68ff8 simplify location access for Android Auto app
extra CarLocationService is not needed, this can be done within CarAppService
2022-06-22 22:09:29 +02:00
johan12345
a69447bb95 fix IllegalStateException in MapFragment 2022-06-22 21:16:59 +02:00
johan12345
326493f5c1 fix donations text in foss version 2022-06-21 21:47:03 +02:00
Johan von Forstner
6adfda8c33 app description: add link to permissions page 2022-06-17 10:06:44 +02:00
johan12345
d02dd41127 release 1.3.2 2022-06-12 20:17:24 +02:00
johan12345
41bafbcf46 fix issues after Kotlin upgrade 2022-06-12 17:56:10 +02:00
johan12345
c135e87be5 fix build flavor check in MapFragment 2022-06-12 17:44:12 +02:00
johan12345
f6fd8866da update dependencies 2022-06-12 17:43:30 +02:00
johan12345
3c485ff0c0 EnBwAvailabilityDetector: fix crash when maxPowerInKw == null 2022-06-12 17:23:24 +02:00
johan12345
0ca8fb0eee Add ability to refresh availability data
fixes #175
2022-06-12 17:22:50 +02:00
johan12345
dc9f47df8a EnBW AvailabilityDetector: support "OUT_OF_SERVICE" status 2022-06-12 16:23:22 +02:00
johan12345
4fab0fbf04 remove label "MapFragment"
(which sometimes appears for a short time during navigation)
2022-06-10 22:00:11 +02:00
johan12345
7bdd277c92 fix color for filteredAvailability 2022-06-10 21:47:16 +02:00
johan12345
3c3d6de867 properly handle opening hours that go past midnight 2022-06-10 21:42:29 +02:00
johan12345
b9d79994f1 detail view: do not show cost description twice
if freeparking and freecharging == null
2022-06-10 21:19:57 +02:00
johan12345
133a2be961 fix layout issues with long charger names 2022-06-10 21:11:23 +02:00
johan12345
cd934ff448 update stored favorite data when loading its details 2022-06-10 20:57:29 +02:00
johan12345
518cf11dc8 Release 1.3.1 2022-06-09 22:02:05 +02:00
Johan von Forstner
2f3d4dd90e switch car app category to POI 2022-06-09 22:00:57 +02:00
johan12345
c8b2c34f47 fix unit tests broken with a7c18fc325 2022-06-09 21:48:21 +02:00
johan12345
57a16ec5f8 use POST requests for GoingElectric instead of GET
fixes #174
2022-06-09 21:48:20 +02:00
johan12345
a4bbb15f64 add option to disable map rotation gestures 2022-06-09 21:27:37 +02:00
johan12345
a7c18fc325 AvailabilityDetectors: increase threshold for merging locations 2022-06-09 19:35:53 +02:00
Johan von Forstner
13034df25e Merge pull request #173 from johan12345/dependabot/bundler/jmespath-1.6.1
Bump jmespath from 1.4.0 to 1.6.1
2022-06-08 07:01:44 +02:00
dependabot[bot]
6e22f26e54 Bump jmespath from 1.4.0 to 1.6.1
Bumps [jmespath](https://github.com/trevorrowe/jmespath.rb) from 1.4.0 to 1.6.1.
- [Release notes](https://github.com/trevorrowe/jmespath.rb/releases)
- [Changelog](https://github.com/jmespath/jmespath.rb/blob/main/CHANGELOG.md)
- [Commits](https://github.com/trevorrowe/jmespath.rb/compare/v1.4.0...v1.6.1)

---
updated-dependencies:
- dependency-name: jmespath
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-07 21:46:24 +00:00
Johan von Forstner
54332e8f77 rename changelog files 2022-06-05 22:21:16 +02:00
Johan von Forstner
c25190e340 fix reading API key from env variable 2022-06-05 22:11:45 +02:00
Johan von Forstner
fc38a9750a fix grep command in GitHub Actions config 2022-06-05 21:54:38 +02:00
Johan von Forstner
4765a18324 fix manifest merge issue 2022-06-05 21:43:10 +02:00
Johan von Forstner
4f98dd1617 Release 1.3.0 2022-06-05 17:37:12 +02:00
Johan von Forstner
c35ca24906 use EnBW AvailabilityDetector only for supported countries 2022-06-05 17:24:04 +02:00
johan12345
8b8ba26a9f add EnBW AvailabilityDetector
fixes #168
2022-05-26 20:41:47 +02:00
johan12345
0b6647a539 fix initial state for invisibleUnlessAnimated 2022-05-26 18:40:59 +02:00
johan12345
b4184dc2de show charge amount and average charge speed in Chargeprice page
fixes #171
2022-05-26 18:18:47 +02:00
johan12345
c105cbfff1 add "allow unbalanced load" setting for Chargeprice
fixes #170
2022-05-26 17:51:45 +02:00
johan12345
5d3633c33d separate version codes for Android + Android Automotive build variants 2022-05-22 20:04:02 +02:00
johan12345
c685086176 change nSpacers 2022-05-22 19:59:05 +02:00
Johan von Forstner
653a84037e Merge pull request #165 from johan12345/android-automotive-buildflavor
Implement Android Automotive OS app
2022-05-22 19:31:28 +02:00
johan12345
e182f8ae82 MapScreen: dynamic loading of availabilities 2022-05-22 19:30:26 +02:00
johan12345
8213aab375 Android Auto: add Chargeprice charging range to settings 2022-05-22 18:40:45 +02:00
johan12345
9c0ffc9cc3 Android Auto(motive): allow access to settings only in park 2022-05-22 17:35:43 +02:00
johan12345
91be9ae0a6 show fav action also on Android Auto 2022-05-22 17:35:43 +02:00
johan12345
15506ce007 add Chargeprice settings to Android Auto(motive) settings screen 2022-05-22 17:35:43 +02:00
johan12345
83ef20d515 update car app library
1.2.0-rc01
2022-05-22 17:35:43 +02:00
johan12345
3c5201e5ca VehicleDataScreen: add heading 2022-05-22 17:35:43 +02:00
johan12345
0db3d1939a Android Automotive: add favorite toggle in ChargerDetailScreen 2022-05-22 17:35:43 +02:00
johan12345
09113591a7 fix permissions for Android Automotive OS 2022-05-22 17:35:43 +02:00
johan12345
c32580e0f5 Android Auto/Automotive: Add settings screen to choose data source 2022-05-22 17:35:43 +02:00
johan12345
b89a5a91ac adjust CI configuration for new build flavor 2022-05-22 17:35:43 +02:00
johan12345
6e178058d9 Start implementing Android Automotive OS app based on build flavor
#158
2022-05-22 17:35:43 +02:00
johan12345
f10bda63e5 upgrade Android Gradle plugin 2022-05-22 17:35:30 +02:00
johan12345
12b4bf359a fix logo in drawer being shown behind status bar
regression introduced through dependency upgrade in f12ed008dd
2022-04-24 21:47:16 +02:00
Johan von Forstner
95bff6d8d4 Fix #167: Data source change only applied after app restart 2022-04-18 20:55:16 +02:00
johan12345
2434e5e75e Android Auto: fix crash when opening hours have no text 2022-03-26 14:33:36 +01:00
johan12345
ba55f1bc8a OpenChargeMap ZonedDateTimeAdapter: fallback to UTC if zone not given 2022-03-20 16:02:04 +01:00
johan12345
57747a9f01 fix issues after dependency update 2022-03-20 15:48:20 +01:00
johan12345
f12ed008dd update some dependencies 2022-03-08 20:35:45 +01:00
johan12345
2f0543a639 recreate index in migration 2022-02-06 23:06:38 +01:00
johan12345
d7a769a0c1 use ForeignKey.NO_ACTION for Favorites
(conflicts with REPLACE during insert)
2022-02-06 20:30:45 +01:00
johan12345
0f1d98bd61 create DB indices as suggested by Room 2022-02-06 14:46:01 +01:00
Johan von Forstner
9ce5685ca1 Merge pull request #163 from johan12345/ci
Remove Travis CI, improve GitHub Actions
2022-02-01 21:55:15 +01:00
johan12345
a364c27e2b update Build Status badge in README.md 2022-02-01 21:47:38 +01:00
johan12345
870bcd25f5 fix Android Lint error 2022-02-01 21:44:32 +01:00
johan12345
84d8b6ca54 Remove Travis CI, improve GH Actions
- run Android Lint
- publish release builds
2022-02-01 21:44:32 +01:00
Johan von Forstner
73968324b8 Merge pull request #160 from dbrgn/osm
OpenStreetMap data parser
2022-02-01 18:51:01 +01:00
Johan von Forstner
7d1093b8c0 Merge pull request #162 from dbrgn/fix-compilation
Fix handling of maxPower in Google build variant
2022-02-01 18:49:50 +01:00
Danilo Bargen
dc7fe22614 Fix handling of maxPower in Google build variant 2022-02-01 11:56:14 +01:00
Danilo Bargen
f082165369 Make Chargepoint.address nullable 2022-02-01 11:53:10 +01:00
Danilo Bargen
d5aa1da7ba Document all fields of ChargeLocation 2022-02-01 11:36:03 +01:00
Danilo Bargen
ab30cd6eab OSM: Set barrierFree to proper value 2022-02-01 11:36:03 +01:00
Danilo Bargen
322d6bea07 OSM: Parse cost 2022-02-01 11:36:03 +01:00
Danilo Bargen
038da0856e OSM: Implement output power parsing 2022-02-01 11:36:03 +01:00
Danilo Bargen
9548748a64 OSM: Map socket types to EVMap Chargepoint types 2022-02-01 11:36:03 +01:00
Danilo Bargen
392c2ecc9a OSM: First incomplete parsing of chargepoints 2022-02-01 11:36:03 +01:00
Danilo Bargen
04723a5c58 Add OSMChargingStation model
This allows deserializing a single model from JSON.
2022-02-01 11:36:03 +01:00
Johan von Forstner
3352328930 Merge pull request #161 from dbrgn/ci
Fix tests and add CI
2022-02-01 07:49:38 +01:00
Johan von Forstner
8229b92d43 Merge pull request #159 from dbrgn/chargepoint-power-nullable
Make Chargepoint.power nullable
2022-02-01 07:46:40 +01:00
Danilo Bargen
f73881a69a Set up CI with GitHub Actions 2022-01-30 23:05:02 +01:00
Danilo Bargen
a53659f835 Add gradle test logger plugin
This allows actually seeing the test results when running them in the
console through Gradle:

    ./gradlew cleanTestFossDebugUnitTest testFossDebugUnitTest
2022-01-30 23:05:02 +01:00
Danilo Bargen
ea94d5bf03 Fix unit tests 2022-01-30 23:05:02 +01:00
Danilo Bargen
85bf04504b Make Chargepoint.power nullable
Not all data sources provide power for all connectors (e.g.
OpenChargeMap and OpenStreetMap don't always). Even without the power
level, knowing what connectors there are is still useful.
2022-01-30 21:53:00 +01:00
johan12345
f591829cb5 make data source button more obvious (fixes #154) 2022-01-30 14:36:32 +01:00
johan12345
d69b5b3d3f Onboarding: add hint that map data source can be changed in app settings 2022-01-30 14:33:13 +01:00
Johan von Forstner
d1f5714bf5 Merge pull request #157 from johan12345/db-restructure
Database restructuring in preparation for new features
2022-01-30 14:17:11 +01:00
johan12345
14229a9c90 add timeRetrieved and isDetailed fields to ChargeLocation 2022-01-30 14:06:47 +01:00
johan12345
e505fea043 create separate database table for favorites
to make ChargeLocation table usable for caching and offline storage (#88, #97) and to allow for multiple favorites lists later (#127)
2022-01-30 12:06:59 +01:00
johan12345
ac3d0b0eb0 update copyright year 2022-01-28 22:50:50 +01:00
johan12345
04aa8d1160 Android Auto: implement experimental setOnContentRefreshListener API
has no effect yet on Android Auto 7.3
2022-01-28 22:46:44 +01:00
johan12345
7b3735f8e8 upgrade car app library to 1.2.0-beta02 2022-01-28 22:37:28 +01:00
Johan von Forstner
9545d729f1 Update Google Maps SDK 2022-01-08 18:09:56 +01:00
244 changed files with 9621 additions and 2863 deletions

8
.github/workflows/apikeys-ci.xml vendored Normal file
View File

@@ -0,0 +1,8 @@
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">ci</string>
<string name="mapbox_key" translatable="false">ci</string>
<string name="goingelectric_key" translatable="false">ci</string>
<string name="chargeprice_key" translatable="false">ci</string>
<string name="openchargemap_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
</resources>

78
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
on:
push:
tags:
- '*'
name: Release
jobs:
test:
name: Build and upload release
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
run: openssl aes-256-cbc -K ${{ secrets.encrypted_53968681344a_key }} -iv ${{ secrets.encrypted_53968681344a_iv }} -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s\+[0-9]*" app/build.gradle | awk '{ print $2 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
- name: release
uses: actions/create-release@v1
id: create_release
with:
draft: false
prerelease: false
release_name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref }}
body_path: fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
env:
GITHUB_TOKEN: ${{ github.token }}
- name: upload Google artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/googleNormal/release/app-google-normal-release.apk
asset_name: app-google-normal-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Foss artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fossNormal/release/app-foss-normal-release.apk
asset_name: app-foss-normal-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Google Automotive artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/googleAutomotive/release/app-google-automotive-release.apk
asset_name: app-google-automotive-release.apk
asset_content_type: application/vnd.android.package-archive

36
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
on:
pull_request:
push:
branches:
- '*'
name: Tests
jobs:
test:
name: Build and Test (${{ matrix.buildvariant }})
runs-on: ubuntu-latest
strategy:
matrix:
buildvariant: [ FossNormal, GoogleNormal, GoogleAutomotive ]
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up Java environment
uses: actions/setup-java@v2
with:
java-version: 11
distribution: 'zulu'
cache: 'gradle'
- name: Copy apikeys.xml
run: cp .github/workflows/apikeys-ci.xml app/src/main/res/values/apikeys.xml
- name: Build app
run: ./gradlew assemble${{ matrix.buildvariant }}Debug --no-daemon
- name: Run unit tests
run: ./gradlew test${{ matrix.buildvariant }}DebugUnitTest --no-daemon
- name: Run Android Lint
run: ./gradlew lint${{ matrix.buildvariant }}Debug --no-daemon

View File

@@ -1,46 +0,0 @@
language: java
dist: focal
env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
- secure: LQHMdhaPUlCuJPFrCPpUphJSY6xzAFI/7RrcAVLtLcPhGdS+MeNifIkkAH7MeitTHroOC0dGkZ4bg/8/7bKfgwY4vPH9P50kZcnX5mI6zfBHgNYJzuthj+vJH9RAtkdQOW9Fe1uPIx8R9GUWUOVnkoJh0PQ1gDXdZW5fePqUtn1kYrcCCBE+Bhe3wz6QzTBqGS1nsVRTxQfSJNGi9uH1oi9kQGgQFuCCiJ/P0A6MIhSItkOfuggx/iorA+iASbhWkB4nXYQBbFe/ZhFJWbVfgYlOM0HtpKh8B2AqKw21Em32JoovCbUof4adkY7cH8/4Rt9SujC9YOw+a6oM+e//jJT0sie77V7zl670j+qODTuNvV4qVUwtoxShyc1Sfbd+Xb0xn/OC7DzBg97YuYCF/84yyuq12rl/cofynWE1L5YvGNSJk241XUw98Bvl0MK4VIfQvG9zJP0HnQZcWKt6kFOIEJSCRbmkd2tPPAZFBXBQf/bvpULOoKwneGJZBSapRoCyGwemM+EAzVB9UOXAqsXZ4FHkt1SSJVrTVwgxvXpCfmF6LZPhbz6nvouRWGsC/GdWjrHtdW5lEOvS27qKEL5rXwQ0o+71ZICGo8j4E0GOHXyi857qZhvO7cbOnts+iiawXiWzPXv2gGGabuqPwcU8JPEoWdaiIaeGUczfjBU=
- secure: fvPVjj3l+TZ7HF5aGn/pmrkipGIrz+MkKNy3I7pnCJSuD/oVp9nQ5ePP/dAhaRThaW+fQbq7hOmCquPAtfoN9CUnHNV2f2l9RavDQIxdqvpXqY13A0BFffZho6A6H2kO7k6kQQPQEhl4SMJjObnX12/YDaTVx3b7aIroEJ8DyY62xGTsjExtaAksuFwUEekjh0MoWICvyBoDfrYhpiEVI2721rGMHu7FIXwmE38+jj7wwZd3Bp37yI9NY/b3ZQ/HUKyYDuoAL0xl5/GaQlRepD0v2xWQUQ40NArHLfMoscXi55UaENuswCg7rt9os8jCcZ8FkZf1cVsQ71JrE0uxgs00Jfjy2QKM5u1XUZefl1Nw5cfCDTWXIEGsz9OGiidFLehWUupX/6C6wr1BStdlRt+6Pt/FXsYHxO/qog++cKqHjOJRXi+raGAb99HhQ/hLnLUMKl5DIWlKF9DImXiOpfYxrgCJc3y91vNX6noJyWYs6PvErMukTsXFHen+fM0NtfTFoKW682oILvXjoeFvuzKpk49+rcpkJbRi5+Zdo/duSPp/flwvC4LOMi0RZOO9TNMhWKdkyWweDr1HEpvQn6RS87rpHzQwRDvm85F+PkZLMMqyWpuxBWbJf0jVbew21KvTJWamuizsIgCebFh0SSxgObzmMbAIFCkzL0PRsms=
- ANDROID_HOME=$HOME/android-sdk
before_install:
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
install:
# Download and unzip the Android command line tools (if not already there thanks to the cache mechanism)
# Latest version of this file available here: https://developer.android.com/studio/#command-tools
- if test ! -e $HOME/android-cmdline-tools/cmdline-tools.zip ; then curl https://dl.google.com/android/repository/commandlinetools-linux-6609375_latest.zip > $HOME/android-cmdline-tools/cmdline-tools.zip ; fi
- unzip -qq -n $HOME/android-cmdline-tools/cmdline-tools.zip -d $HOME/android-cmdline-tools
# Install or update Android SDK components (will not do anything if already up to date thanks to the cache mechanism)
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platform-tools' > /dev/null
# Latest version of build-tools available here: https://developer.android.com/studio/releases/build-tools.html
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'build-tools;29.0.3' > /dev/null
- echo y | $HOME/android-cmdline-tools/tools/bin/sdkmanager --sdk_root=$HOME/android-sdk 'platforms;android-29' > /dev/null
script:
- "./gradlew lintFossDebug testFossDebugUnitTest lintGoogleDebug testGoogleDebugUnitTest"
- "./gradlew assembleRelease"
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
- "$HOME/android-cmdline-tools"
- "$HOME/android-sdk"
deploy:
provider: releases
api_key:
secure: "XQR4GUrGkPKYVV0xMbJifX/ewKAnenBPlM/pPacQ9irAmYNYa/yEkySz4x1K6MP8cEnuJbxHFakcDqhNRCqD7Cq2NcnCi3qtTEXHK6ApLoVl/92eyiWxu/bYlidOEZb+YPcVNtTR253NiI8GYda+CrhLd4uCmsAgES+XPFJd/t2esMlDOSAp7xalZv/zFhhlB9+SevfPFMc6kkrqeHpKnMs9SK8ltVQmh3nch2KjtDvqgDW6d3nuwn7/HAer6/HY86hmA4Rh6Mo2cV6OloX0bdJ7hvA1GOT4p3+K3lWbTRxzE0o1DXAtT7+D158iKvxHFPuF3h+CTjSlLeiss6kQZL9nFjw/KhAvu+GJOp37PcMoI++mpMiFoWPlzKpp17BVKIDinYbgi8kiU4zG+QHhe2cY85SbfAplXUaysq7uzxEZwEUYHSAHNahshVooXRqvuzkthcH0/nvinfeXrzx2xDvQ3if1NENMRgttwewU0kvU61iKUwpcf/UN2bHK3DaPes0VzSH4PTHAGjoRpksDfqUwb7S8YxbYr+44aMbSPYN8Lbjda0BxPSKWwHM5/pi7FBJN1a1w3t7sV/EiACWUWr8OovmX4ljyCybbR0w9cPzRC1zAYeSUHslLXMTW2Pp9h594RnYh3q3VfeYlFCikFvuvrafwXmTkz35uhLb+2ws="
file:
- app/build/outputs/apk/foss/release/app-foss-release.apk
- app/build/outputs/apk/google/release/app-google-release.apk
on:
repo: johan12345/EVMap
tags: true
skip_cleanup: 'true'

View File

@@ -113,7 +113,7 @@ GEM
http-cookie (1.0.3)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.4.0)
jmespath (1.6.1)
json (2.3.1)
jwt (2.2.1)
memoist (0.16.2)

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2021 Johan von Forstner
Copyright (c) 2020-2022 Johan von Forstner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,4 +1,4 @@
EVMap [![Build Status](https://app.travis-ci.com/johan12345/EVMap.svg?branch=master)](https://app.travis-ci.com/johan12345/EVMap)
EVMap [![Build Status](https://github.com/johan12345/EVMap/actions/workflows/tests.yml/badge.svg)](https://github.com/johan12345/EVMap/actions)
=====
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
@@ -20,7 +20,7 @@ Features
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- Android Auto & Android Automotive OS integration
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
@@ -41,9 +41,24 @@ EVMap uses and put them into the app in the form of a resource file called `apik
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
and should run on devices without Google Play Services.
There are three different build flavors, `googleNormal`, `fossNormal` and `googleAutomotive`.
- The `foss` variant only uses Mapbox data and should run on most Android devices, even without
Google Play Services.
- The `google` variants also include access to Google Maps data.
- `googleNormal` is intended to run on smartphones and tablets, and also includes the Android
Auto app for use on the car display.
- `googleAutomotive` variant is intended to be installed directly on car infotainment systems
using the Google-flavored Android Automotive OS. It does not provide the usual smartphone UI.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.
Translations
------------
You can use our [Weblate page](https://hosted.weblate.org/projects/evmap/) to help translate EVMap
into new languages.
<a href="https://hosted.weblate.org/engage/evmap/">
<img src="https://hosted.weblate.org/widgets/evmap/-/open-graph.png" width="500" alt="Translation status" />
</a>

View File

Binary file not shown.

65
_img/app_logo.svg Normal file
View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 663.6 219.8" style="enable-background:new 0 0 663.6 219.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFB300;}
.st1{fill:#90A4AE;}
.st2{fill:#546E7A;}
.st3{fill:#00E676;}
.st4{fill:#FFFFFF;fill-opacity:0.2;}
.st5{fill:#3E2723;fill-opacity:0.2;}
.st6{opacity:0.45;enable-background:new ;}
.st7{enable-background:new ;}
.st8{fill:#1D1D1B;}
</style>
<g id="Ebene_2_1_">
<g>
<g>
<g>
<path class="st0"
d="M19.4,161.7l-4-35.1l-6.1,0.6l4,35.1L19.4,161.7z M41.2,159.1l-4-35.1l-6.1,0.6l4,35.1L41.2,159.1z" />
<path class="st1" d="M52.6,206.9c-1.9,2.3-3.4,3.8-3.6,4c-5.5,4.4-9.9,5.7-13.5,4c-6.3-3.2-5.9-15-5.7-16.3l4.4,0.2
c-0.2,3.4,0.4,10.6,3.4,12c1.7,0.8,4.6-0.2,8.5-3.4l0,0c0,0,12.3-12.3,9.7-22c-3-11.6,10.6-28.3,15-34l0.6-0.6l3.6,2.7l-0.6,0.8
c-13.7,16.9-15.2,25.6-14.2,30C62.3,192.9,56.6,202,52.6,206.9z" />
<path class="st1"
d="M5.9,161.2l1.7,14.4l13.3,8.9l18-1.9l11-11.6l-1.7-14.4L5.9,161.2z" />
<g>
<path class="st2" d="M38.6,182.6l-18,1.9l3.8,15.8l14.2-1.7V182.6L38.6,182.6z M51.5,144.5l1.5,13.1l-51.5,5.9L0,150.4
L51.5,144.5z" />
</g>
</g>
<g>
<g>
<path class="st3" d="M91.9,0c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6
c6.8-72,63.2-98.4,63.2-148.9C158.8,29.8,128.8,0,91.9,0z" />
<path class="st4" d="M91.9,1.5c36.8,0,66.5,29.6,67,66.1c0-0.2,0-0.4,0-0.6c0-37-30-67-67-67s-67,29.8-67,67c0,0.2,0,0.4,0,0.6
C25.3,31.1,55.1,1.5,91.9,1.5L91.9,1.5z" />
<path class="st5" d="M95.9,214.3c-0.2,2.1-1.9,3.6-4,3.6s-3.8-1.5-4-3.6c-6.5-71.8-62.5-98.2-63-148.1c0,0.4,0,0.6,0,1.1
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
C158.4,116,102.4,142.4,95.9,214.3L95.9,214.3z" />
</g>
<path class="st6"
d="M76.5,34.3v40.6h11v33.2l25.8-44.4H98.5l14.8-29.6C113.4,34.3,76.5,34.3,76.5,34.3z" />
</g>
</g>
<g class="st7">
<path class="st8"
d="M307.9,102.9h-45.4v39.6h52.2v6.9h-60.4V52.3h60.1v6.9h-51.9v36.7h45.4V102.9z" />
<path class="st8"
d="M361.2,137.4l0.5,2.1l0.6-2.1l30.5-85.1h9l-36.1,97.1h-7.9l-36.1-97.1h8.9L361.2,137.4z" />
<path class="st8" d="M427,52.4l35.8,85.7l35.9-85.7h10.9v97.1h-8.2v-42.3l0.7-43.3L466,149.5h-6.3l-36-85.3l0.7,42.7v42.5h-8.2
V52.3H427V52.4z" />
<path class="st8" d="M578,149.4c-0.8-2.3-1.3-5.6-1.5-10.1c-2.8,3.6-6.4,6.5-10.7,8.4c-4.3,2-8.9,3-13.8,3
c-6.9,0-12.5-1.9-16.8-5.8s-6.4-8.8-6.4-14.7c0-7,2.9-12.6,8.8-16.7c5.8-4.1,14-6.1,24.4-6.1h14.5v-8.2c0-5.2-1.6-9.2-4.8-12.2
s-7.8-4.4-13.9-4.4c-5.6,0-10.2,1.4-13.8,4.3c-3.6,2.8-5.5,6.3-5.5,10.3l-8-0.1c0-5.7,2.7-10.7,8-14.9s11.9-6.3,19.7-6.3
c8,0,14.4,2,19,6s7,9.6,7.2,16.8v34.1c0,7,0.7,12.2,2.2,15.7v0.8H578V149.4z M552.9,143.7c5.3,0,10.1-1.3,14.3-3.9
s7.3-6,9.2-10.3v-15.9h-14.3c-8,0.1-14.2,1.5-18.7,4.4c-4.5,2.8-6.7,6.7-6.7,11.6c0,4,1.5,7.4,4.5,10.1S548.1,143.7,552.9,143.7z
" />
<path class="st8" d="M663.6,114.1c0,11.2-2.5,20.2-7.5,26.8s-11.6,9.9-20,9.9c-9.9,0-17.4-3.5-22.7-10.4v36.8h-7.9V77.3h7.4
l0.4,10.2C618.5,79.8,626,76,635.9,76c8.6,0,15.4,3.3,20.3,9.8c4.9,6.5,7.4,15.6,7.4,27.2V114.1z M655.6,112.7
c0-9.2-1.9-16.5-5.7-21.8s-9-8-15.8-8c-4.9,0-9.1,1.2-12.6,3.5c-3.5,2.4-6.2,5.8-8.1,10.3v34.6c1.9,4.1,4.6,7.3,8.2,9.5
c3.6,2.2,7.8,3.3,12.6,3.3c6.7,0,11.9-2.7,15.7-8C653.7,130.6,655.6,122.9,655.6,112.7z" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 120 120" style="enable-background:new 0 0 120 120;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<g>
<path class="st0"
d="M27.1,88.3l-2.2-19.2l-3.3,0.3l2.2,19.2L27.1,88.3z M39,86.9l-2.2-19.2l-3.3,0.3l2.2,19.2L39,86.9z" />
<path class="st0" d="M45.2,113c-1,1.3-1.8,2.1-2,2.2c-3,2.4-5.4,3.1-7.4,2.2c-3.5-1.7-3.2-8.2-3.1-8.9l2.4,0.1
c-0.1,1.8,0.2,5.8,1.8,6.6c0.9,0.5,2.5-0.1,4.6-1.8l0,0c0,0,6.7-6.7,5.3-12c-1.6-6.4,5.8-15.5,8.2-18.6l0.3-0.3l2,1.5l-0.3,0.5
c-7.5,9.2-8.3,14-7.7,16.4C50.5,105.4,47.4,110.4,45.2,113z" />
<path class="st0" d="M19.7,88.1l0.9,7.9l7.3,4.9l9.8-1l6-6.4l-0.9-7.9L19.7,88.1z" />
<g>
<path class="st0"
d="M37.6,99.7l-9.8,1l2.1,8.7l7.7-0.9V99.7L37.6,99.7z M44.6,79l0.8,7.2l-28.2,3.2l-0.8-7.2L44.6,79z" />
</g>
</g>
<path class="st0" d="M66.7,0C46.5,0,30.1,16.4,30.1,36.6c0,27.6,30.8,42,34.5,81.4c0.1,1.2,1,2,2.2,2c1.2,0,2.1-0.8,2.2-2
c3.7-39.4,34.5-53.8,34.5-81.4C103.3,16.2,86.9,0,66.7,0z M78.4,34.7L64.3,59V40.8h-6V18.7c0,0,20.2,0,20.1-0.1l-8.1,16.2H78.4z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,31 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{display:none;}
.st2{display:inline;fill:#802C27;}
.st3{fill:#808080;}
.st4{display:none;fill:#802C27;}
.st1{fill:#808080;}
</style>
<g>
<g>
<g>
<path class="st0" d="M109.8,0h13.6c33.9,1.9,67.1,18.5,87.7,45.8c13.5,17.2,21,38.6,22.7,60.3v8.1c-0.8,42.1-27.7,76.6-51,109.4
c-26.2,37-50.4,77.3-57.1,122.9c-1.8,7.7,0.4,18.5-8.9,22c-2.2-1.7-4.7-3.1-6.2-5.4c-2.7-25.5-9.1-50.7-20-73.9
c-12.3-27.1-29.5-51.6-47-75.6C33,199,23,184.2,14.7,168.3c-13-23.8-17.9-51.9-12.5-78.6c4.4-21.1,15.4-40.6,30.6-55.7
C53.3,14,81.1,1.8,109.8,0z" />
</g>
</g>
<g class="st1">
<path class="st2" d="M107.2,74.1c18.9-4.8,40.4,5.5,47.7,23.7c6.1,14.5,1.9,32.5-9.9,42.9c-12.6,11.5-32.4,14-47.5,6
c-13.9-6.8-23-22.6-21.3-38.1C77.6,92,91.1,77.7,107.2,74.1z" />
</g>
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
C123.3,359,122.8,364.9,117,367.4z" />
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
L123.4,0z" />
</g>
<path class="st3" d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6H90.9z" />
<path class="st4" d="M159,85.3L159,85.3l-20.8-20.9l-5.9,5.9l11.8,11.8c-5.3,2-9,7.1-9,13.1c0,7.7,6.3,14,14,14c2,0,3.9-0.4,5.6-1.2
v40.4c0,3.1-2.5,5.6-5.6,5.6s-5.6-2.5-5.6-5.6v-25.2c0-6.2-5-11.2-11.2-11.2h-5.6V72.8c0-6.2-5-11.2-11.2-11.2H81.8
c-6.2,0-11.2,5-11.2,11.2v89.7h56.1v-42.1h8.4v28c0,7.7,6.3,14,14,14s14-6.3,14-14V95.2C163.1,91.3,161.6,87.8,159,85.3
M149.1,100.8c-3.1,0-5.6-2.5-5.6-5.6c0-3.1,2.5-5.6,5.6-5.6s5.6,2.5,5.6,5.6C154.7,98.3,152.2,100.8,149.1,100.8 M93.1,145.6v-25.2
H81.8l22.4-42.1v28h11.2L93.1,145.6z" />
<path class="st1"
d="M90.9,57.3v68.2h18.6v55.8l43.4-74.4h-24.8l24.8-49.6C152.9,57.3,90.9,57.3,90.9,57.3z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,27 +1,28 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 233.8 368.4" style="enable-background:new 0 0 233.8 368.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#B5B5B5;}
.st2{fill:#808080;}
.st1{fill:#808080;}
.st2{fill:#B5B5B5;}
</style>
<g>
<g>
<g>
<path class="st0" d="M109.8,0h13.6c33.9,1.9,67.1,18.5,87.7,45.8c13.5,17.2,21,38.6,22.7,60.3v8.1c-0.8,42.1-27.7,76.6-51,109.4
c-26.2,37-50.4,77.3-57.1,122.9c-1.8,7.7,0.4,18.5-8.9,22c-2.2-1.7-4.7-3.1-6.2-5.4c-2.7-25.5-9.1-50.7-20-73.9
c-12.3-27.1-29.5-51.6-47-75.6C33,199,23,184.2,14.7,168.3c-13-23.8-17.9-51.9-12.5-78.6C6.6,68.6,17.6,49.1,32.8,34
C53.3,14,81.1,1.8,109.8,0z" />
</g>
</g>
</g>
<g>
<polygon class="st1"
points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
<path class="st1"
d="M122.2,101.9h16.7h5.7l22.3-44.6c0,0-10.2,0-22.4,0l-1.1,2.2L122.2,101.9z" />
<path class="st2" d="M138.9,57.3c-9.7,0-19.8,0-26.4,0c-2.5,0-5.1,0-7.6,0c-8.2,0-16.1,0-21.4,0c-4.1,0-6.6,0-6.6,0v68.2h18.6v55.8
l43.4-74.4h-24.8L138.9,57.3z" />
<path class="st0" d="M117,367.4c-0.4-0.3-0.8-0.6-1.2-0.9c-1.6-1.2-3.1-2.3-4.2-3.7c-2.9-26.9-9.6-51.7-20.1-74
c-12.4-27.3-30.1-52.4-47.1-75.8c-8.7-12-19.8-27.9-28.8-45.2C2.3,143.6-2.1,115.9,3.2,89.9c4.3-20.4,15-40,30.3-55.2
C53.6,15.1,81.5,2.8,109.9,1l13.5,0c34.4,1.9,66.9,18.9,86.9,45.4c12.8,16.3,20.8,37.5,22.5,59.8l0,8
c-0.7,38.8-23.7,70.9-45.9,101.9c-1.7,2.3-3.3,4.6-5,6.9c-24.4,34.5-50.3,76.1-57.3,123.3c-0.5,2-0.7,4.3-0.9,6.5
C123.3,359,122.8,364.9,117,367.4z" />
<path class="st1" d="M123.3,2c34.1,1.9,66.3,18.8,86.2,45c12.6,16.1,20.5,37.1,22.3,59.1l0,8c-0.7,38.5-23.6,70.5-45.7,101.3
c-1.7,2.3-3.3,4.6-5,6.9c-24.5,34.6-50.5,76.3-57.4,123.7c-0.5,2.1-0.7,4.4-0.9,6.7c-0.5,5.9-1,11-5.8,13.4
c-0.2-0.2-0.5-0.4-0.7-0.5c-1.5-1.1-2.9-2-3.8-3.3c-2.9-26.9-9.7-51.8-20.1-74C80,261,62.3,235.8,45.2,212.4
c-8.7-11.9-19.8-27.8-28.8-45.1C3.3,143.3-1,115.9,4.2,90.1c4.2-20.2,14.9-39.6,30-54.7C54.2,16,81.7,3.8,109.9,2H123.3 M123.4,0
h-13.6c-28.7,1.8-56.5,14-77,34C17.6,49.1,6.6,68.6,2.2,89.7c-5.4,26.7-0.5,54.8,12.5,78.6C23,184.2,33,199,43.6,213.6
c17.5,24,34.7,48.5,47,75.6c10.9,23.2,17.3,48.4,20,73.9c1.5,2.3,4,3.7,6.2,5.4c9.3-3.5,7.1-14.3,8.9-22
c6.7-45.6,30.9-85.9,57.1-122.9c23.3-32.8,50.2-67.3,51-109.4v-8.1c-1.7-21.7-9.2-43.1-22.7-60.3C190.5,18.5,157.3,1.9,123.4,0
L123.4,0z" />
</g>
<polygon class="st2" points="143.2,109.4 123.5,143.2 123.5,181.3 166.9,106.9 144.7,106.9 " />
<path class="st2" d="M122.2,101.9h16.7h5.7l22.3-44.6c0,0-10.2,0-22.4,0l-1.1,2.2L122.2,101.9z" />
<path class="st1" d="M138.9,57.3c-9.7,0-19.8,0-26.4,0c-2.5,0-5.1,0-7.6,0c-8.2,0-16.1,0-21.4,0c-4.1,0-6.6,0-6.6,0v68.2h18.6v55.8
l43.4-74.4h-24.8L138.9,57.3z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 566.9 254.9" style="enable-background:new 0 0 566.9 254.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:#000044;}
</style>
<path class="st0" d="M60.8,86.3c-5.6,0-10,0.5-13.3,1.4c-3.3,0.9-5.7,2.7-7.1,5.2c-0.4,0.6-0.7,1.3-0.9,2c-1.1,3.3-1.4,6.8-1.4,10.2
c0,2.1,0,4.2,0,6.2c0,0.1,0,0.2,0,0.3h22.3v20.9H38.1v71.2v7.6H14.5v-7.6v-71.2H0v-20.9h14.5V104c0-13.4,3.9-23.8,11.2-31.3
c7.3-7.4,19-10.8,35.1-10V86.3 M157.1,161.2c0-15.5,11.2-27.3,25.7-27.3c14.5,0,25.5,11.8,25.5,27.3c0,15.6-11,27.3-25.5,27.3
C168.3,188.5,157.1,176.9,157.1,161.2 M182.8,110.1c-27.9,0-49.7,22.2-49.7,51.1c0,29.1,21.8,51.3,49.7,51.3
c27.9,0,49.4-22.2,49.4-51.3C232.1,132.3,210.7,110.1,182.8,110.1 M541.4,161.5c14.1,0,25.6-11.5,25.6-25.6
c0-14.1-11.5-25.6-25.6-25.6c-14.1,0-25.6,11.5-25.6,25.6C515.8,150,527.2,161.5,541.4,161.5 M129.6,110.5c-2.2-0.2-4.3-0.4-6.6-0.4
c-4.4,0-9.1,0.6-13.9,1.9c-4.8,1.3-8.5,3.4-10.7,6.5v-7H74.7V211h23.8v-58.2c0-5.4,1.4-9.5,4.1-12.3c4.4-4.5,10.6-6.5,16.7-6.8
c1.7-0.1,3.5,0,5.3,0.1c1.6,0.2,3.2,0.5,4.7,0.7c0-1,0-2,0-3c0-2,0-3.9,0-5.9c0-2.2,0-4.5,0-6.7c0-1.9,0.2-3.8,0.2-5.7
c0-0.9,0.2-1.7,0.2-2.6C129.6,110.6,129.6,110.5,129.6,110.5z M475.8,160.6l29.7-49h-28.7l-16.3,31.7l-16.5-31.7H415l30.1,49
l-30.7,50.6h28.7l17.3-33.1l17.7,33.1h28.7L475.8,160.6z M356.9,254.8c14.9,0.5,26.7-3.1,34.7-10.9c7.6-7.4,11.6-18.1,12-33l0,0
v-2.4v-97.1h-24.5c0,0,0,14.3,0,28.8v14.9v0.1v14.5c0,5.3-1.2,9.7-5.8,13.8c-4.3,3.9-10.9,5.1-15.4,5.1c-2.7,0-10.7-0.9-15.4-7.5
c-2.4-3.4-3.9-9.1-4.2-14c-0.3-5-0.3-7.4-0.6-14.7c-1.1-30.2-26-47.6-55-40.3c-5,1.3-9,3.4-11.2,6.5v-7.2h-23.8V211h23.8v-52v-6
c0-5.4,1.5-9.5,4.3-12.3c2.8-2.7,5.8-4.2,9.5-5.1c3.7-0.9,6.6-1,8.5-1c2.9,0,6.5,0.8,8,1.2c2.8,0.8,6.3,3.3,8.3,6.2
c3.7,5.3,3.3,12.8,3.5,18.9c0.2,7.6,0.4,15.3,2.7,22.7c3.2,10.3,9.7,20,19.7,24.6c9,4.1,20,5.2,29.7,3.4c3-0.6,6.3-1.5,9.1-3
c1.6-0.9,3.2-1.9,4.5-3.2c0.4,10.7,0.6,17.6-6,22.5c-1.8,1.4-4.3,2.5-6.5,2.9c-3.1,0.6-6.5,1-9.7,0.9L356.9,254.8z" />
<g>
<g>
<path class="st0" d="M97.6,89.8V39.2c0-1.6-0.1-3.2-0.2-4.8c-0.1-1.7-0.3-3.3-0.5-4.9h6.6l0.9,10h-1c0.9-3.3,2.7-5.9,5.5-7.9
c2.7-1.9,6-2.9,9.8-2.9c3.8,0,7.1,0.9,9.9,2.6c2.8,1.7,4.9,4.2,6.5,7.5c1.6,3.2,2.4,7.2,2.4,11.8c0,4.5-0.8,8.4-2.3,11.7
c-1.5,3.2-3.7,5.8-6.5,7.5c-2.8,1.8-6.1,2.6-9.9,2.6c-3.8,0-7-1-9.7-2.9c-2.7-1.9-4.6-4.5-5.5-7.8h0.9v28.1H97.6z M117.3,66.8
c4,0,7.2-1.4,9.6-4.2c2.4-2.8,3.6-6.8,3.6-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2
c-2.4,2.8-3.6,6.8-3.6,12.2c0,5.3,1.2,9.4,3.6,12.1C110.2,65.4,113.4,66.8,117.3,66.8z" />
<path class="st0" d="M165.4,72.4c-4.1,0-7.6-0.9-10.6-2.6c-3-1.8-5.3-4.3-6.9-7.6c-1.6-3.3-2.4-7.2-2.4-11.6
c0-4.5,0.8-8.4,2.4-11.7c1.6-3.2,3.9-5.8,6.9-7.5c3-1.8,6.5-2.6,10.5-2.6c4.1,0,7.6,0.9,10.6,2.6c3,1.8,5.3,4.3,7,7.5
c1.7,3.2,2.5,7.1,2.5,11.7c0,4.5-0.8,8.4-2.5,11.6c-1.7,3.3-4,5.8-7,7.6C172.9,71.5,169.4,72.4,165.4,72.4z M165.4,66.8
c4,0,7.1-1.4,9.5-4.2c2.4-2.8,3.5-6.8,3.5-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.5-4.2c-4,0-7.1,1.4-9.5,4.2
c-2.4,2.8-3.5,6.8-3.5,12.2c0,5.3,1.2,9.4,3.5,12.1C158.2,65.4,161.4,66.8,165.4,66.8z" />
<path class="st0" d="M207.2,71.6l-15.6-42.2h7.2l13,37.1h-2.2l13.4-37.1h5.9l13.2,37.1h-2.1l13.1-37.1h6.9l-15.7,42.2h-6.6
l-13.6-37.7h3.4l-13.7,37.7H207.2z" />
<path class="st0" d="M287.7,72.4c-6.6,0-11.8-1.9-15.6-5.8c-3.8-3.8-5.7-9.2-5.7-16c0-4.4,0.8-8.3,2.5-11.5c1.7-3.3,4-5.8,7.1-7.6
c3-1.8,6.5-2.7,10.4-2.7c3.9,0,7.1,0.8,9.7,2.4c2.6,1.6,4.6,3.9,6,6.9c1.4,3,2.1,6.5,2.1,10.6v2.5h-32.7v-4.3h28.2l-1.4,1.1
c0-4.5-1-8-3-10.5c-2-2.5-5-3.8-9-3.8c-4.2,0-7.5,1.5-9.8,4.4C274.2,41.1,273,45,273,50v0.8c0,5.3,1.3,9.3,3.9,12
c2.6,2.7,6.3,4.1,11,4.1c2.5,0,4.9-0.4,7.1-1.1c2.2-0.8,4.3-2,6.3-3.7l2.4,4.8c-1.8,1.8-4.2,3.2-7,4.2
C293.8,71.9,290.8,72.4,287.7,72.4z" />
<path class="st0" d="M314.5,71.6v-32c0-1.7,0-3.4-0.1-5.1c-0.1-1.7-0.2-3.4-0.4-5h6.6l0.8,10.2l-1.2,0.1c0.6-2.5,1.5-4.6,2.9-6.2
c1.4-1.6,3.1-2.8,5-3.7c1.9-0.8,3.9-1.2,6-1.2c0.8,0,1.6,0,2.2,0.1c0.6,0.1,1.2,0.2,1.8,0.4l-0.1,6c-0.8-0.3-1.6-0.5-2.3-0.5
s-1.5-0.1-2.4-0.1c-2.5,0-4.6,0.6-6.4,1.8c-1.8,1.2-3.2,2.7-4.1,4.5s-1.4,3.8-1.4,5.9v24.9H314.5z" />
<path class="st0" d="M363.9,72.4c-6.6,0-11.8-1.9-15.6-5.8c-3.8-3.8-5.7-9.2-5.7-16c0-4.4,0.8-8.3,2.5-11.5c1.7-3.3,4-5.8,7.1-7.6
c3-1.8,6.5-2.7,10.4-2.7c3.9,0,7.1,0.8,9.7,2.4c2.6,1.6,4.6,3.9,6,6.9c1.4,3,2.1,6.5,2.1,10.6v2.5h-32.7v-4.3H376l-1.4,1.1
c0-4.5-1-8-3-10.5c-2-2.5-5-3.8-9-3.8c-4.2,0-7.5,1.5-9.8,4.4c-2.4,2.9-3.5,6.9-3.5,11.9v0.8c0,5.3,1.3,9.3,3.9,12
c2.6,2.7,6.3,4.1,11,4.1c2.5,0,4.9-0.4,7.1-1.1c2.2-0.8,4.3-2,6.3-3.7l2.4,4.8c-1.8,1.8-4.2,3.2-7,4.2
C370,71.9,367,72.4,363.9,72.4z" />
<path class="st0" d="M406.8,72.4c-3.7,0-6.9-0.9-9.7-2.6c-2.8-1.8-5-4.3-6.5-7.5c-1.5-3.2-2.3-7.1-2.3-11.7
c0-4.6,0.8-8.5,2.3-11.8c1.5-3.2,3.7-5.7,6.5-7.5c2.8-1.7,6-2.6,9.7-2.6c3.8,0,7.1,1,9.9,2.9c2.8,1.9,4.6,4.5,5.6,7.7h-1V9.8h6.8
v61.8h-6.7V61.5h0.9c-0.9,3.4-2.7,6-5.5,7.9C413.9,71.4,410.6,72.4,406.8,72.4z M408.2,66.8c4,0,7.2-1.4,9.6-4.2
c2.4-2.8,3.6-6.8,3.6-12.1c0-5.4-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2c-2.4,2.8-3.6,6.8-3.6,12.2
c0,5.3,1.2,9.4,3.6,12.1C401.1,65.4,404.3,66.8,408.2,66.8z" />
<path class="st0" d="M484.3,72.4c-3.8,0-7.1-1-9.8-2.9c-2.7-1.9-4.6-4.6-5.5-7.9h0.9v10.1h-6.7V9.8h6.8v29.5h-1
c1-3.2,2.8-5.8,5.5-7.7c2.7-1.9,6-2.9,9.8-2.9c3.8,0,7.1,0.9,9.9,2.6c2.8,1.8,4.9,4.3,6.5,7.5c1.5,3.2,2.3,7.1,2.3,11.7
s-0.8,8.4-2.4,11.7c-1.6,3.2-3.7,5.8-6.5,7.5C491.4,71.5,488.1,72.4,484.3,72.4z M482.9,66.8c4,0,7.2-1.4,9.6-4.1
c2.4-2.7,3.6-6.8,3.6-12.2s-1.2-9.5-3.6-12.2c-2.4-2.8-5.6-4.2-9.6-4.2c-4,0-7.2,1.4-9.5,4.2c-2.4,2.8-3.6,6.8-3.6,12.2
c0,5.3,1.2,9.4,3.6,12.1C475.8,65.4,478.9,66.8,482.9,66.8z" />
<path class="st0" d="M511.9,90.7l-1.6-5.6c2.6-0.6,4.8-1.3,6.6-2.1c1.8-0.8,3.2-1.9,4.4-3.2c1.2-1.3,2.2-3,3-5l2.2-5l-0.2,2.9
l-18.4-43.1h7.4l15.2,37h-2.2l15-37h7.1L531,75.1c-1.1,2.7-2.4,4.9-3.7,6.8c-1.3,1.8-2.8,3.3-4.3,4.5c-1.5,1.1-3.2,2.1-5.1,2.7
S514.1,90.3,511.9,90.7z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -1,33 +1,43 @@
plugins {
id 'com.adarshr.test-logger' version '3.1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'de.timfreiheit.resourceplaceholders'
def supportedLocales = "en,de,fr,nb-rNO"
android {
compileSdkVersion 31
compileSdkVersion 33
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 31
versionCode 68
versionName "1.2.0"
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 142
versionName "1.4.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(",")
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
}
signingConfigs {
release {
def isRunningOnTravis = System.getenv("CI") == "true"
if (isRunningOnTravis) {
def isRunningOnCI = System.getenv("CI") == "true"
if (isRunningOnCI) {
// configure keystore
storeFile = file("../_ci/keystore.jks")
storePassword = System.getenv("keystore_password")
keyAlias = System.getenv("keystore_alias")
keyPassword = System.getenv("keystore_alias_password")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEYSTORE_ALIAS")
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD")
}
}
}
@@ -44,7 +54,7 @@ android {
}
}
flavorDimensions "dependencies"
flavorDimensions "dependencies", "automotive"
productFlavors {
foss {
dimension "dependencies"
@@ -53,6 +63,22 @@ android {
dimension "dependencies"
versionNameSuffix "-google"
}
normal {
dimension "automotive"
}
automotive {
dimension "automotive"
versionNameSuffix "-automotive"
versionCode defaultConfig.versionCode + 1
minSdkVersion 29
}
}
variantFilter { variant ->
def names = variant.flavors*.name
// Android Automotive OS app is always based on Google variant
if (names.contains("automotive") && !names.contains("google")) {
setIgnore(true)
}
}
compileOptions {
@@ -69,6 +95,19 @@ android {
dataBinding = true
viewBinding true
}
lint {
disable 'NullSafeMutableLiveData'
warning 'MissingTranslation'
}
testOptions {
unitTests.includeAndroidResources true
}
resourcePlaceholders {
files = ['xml/shortcuts.xml']
}
namespace 'net.vonforst.evmap'
// add API keys from environment variable if not set in apikeys.xml
applicationVariants.all { variant ->
@@ -85,7 +124,7 @@ android {
variant.resValue "string", "openchargemap_key", openchargemapKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName == 'google') {
if (googleMapsKey != null && variant.flavorName.startsWith('google')) {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
@@ -102,60 +141,77 @@ android {
if (chargepriceKey != null) {
variant.resValue "string", "chargeprice_key", chargepriceKey
}
def fronyxKey = env.FRONYX_API_KEY ?: project.findProperty("FRONYX_API_KEY")
if (fronyxKey == null && project.hasProperty("FRONYX_API_KEY_ENCRYPTED")) {
fronyxKey = decode(project.findProperty("FRONYX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (fronyxKey != null) {
variant.resValue "string", "fronyx_key", fronyxKey
}
}
lintOptions {
disable 'NullSafeMutableLiveData'
}
}
configurations {
googleNormalImplementation {}
googleAutomotiveImplementation {}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.4.0"
implementation 'androidx.appcompat:appcompat:1.6.0-rc01'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.4"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.5.0-rc01'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:3529a5a9f1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
implementation 'com.squareup.moshi:moshi-adapters:1.13.0'
implementation 'moe.banana:moshi-jsonapi:3.5.0'
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
implementation 'com.markomilos.jsonapi:jsonapi-retrofit:1.1.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie: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'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
googleImplementation 'androidx.car.app:app:1.2.0-alpha02'
googleImplementation 'androidx.car.app:app-projected:1.2.0-alpha02'
def carAppVersion = '1.3.0-beta01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
// AnyMaps
def anyMapsVersion = '751daec281'
def anyMapsVersion = 'a9b3dd7d99'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.0.1'
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation("com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion") {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
exclude group: 'com.google.android.gms', module: 'play-services-location'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-core'
}
// patched version of mapbox-android-core that removes build-time dependency on GMS
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
// Google Places
implementation 'com.google.android.libraries.places:places:2.5.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
googleImplementation 'com.google.android.libraries.places:places:2.7.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
@@ -165,18 +221,18 @@ dependencies {
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.4.0"
def lifecycle_version = "2.5.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.4.0"
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "4.0.0"
def billing_version = "4.1.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
@@ -187,18 +243,26 @@ dependencies {
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'
implementation 'com.facebook.stetho:stetho:1.6.0'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
// testing
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.9'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
}
private static String decode(String s, String key) {
@@ -211,4 +275,4 @@ private static byte[] xorWithKey(byte[] a, byte[] key) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
<string name="data_sources_hint">Die Kartendaten für die App stammen von OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.</string>
<string name="data_sources_hint">Les données cartographiques de l\'application sont fournies par OpenStreetMap (Mapbox).</string>
<string name="donate_paypal">Faire un don avec PayPal</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donate_paypal">Doner med PayPal</string>
<string name="data_sources_hint">Kartdata i programmet tilbys av OpenStreetMap (Mapbox).</string>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende en slant til utvikleren.</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>@string/pref_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_search_provider_values" translatable="false">
<item>mapbox</item>
</string-array>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>
<string name="data_sources_hint">Map data in the app is provided by OpenStreetMap (Mapbox).</string>
</resources>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<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>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="net.vonforst.evmap">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
@@ -39,14 +38,8 @@
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.CHARGING" />
android:category="androidx.car.app.category.POI" />
</intent-filter>
</service>
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View File

@@ -7,16 +7,10 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.maps.MapsInitializer
import com.google.android.libraries.places.api.Places
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
fun init(context: Context) {
Places.initialize(context, context.getString(R.string.google_maps_key))
val localeContext = LocaleContextWrapper.wrap(
context.applicationContext, PreferenceDataSource(context).language
)
MapsInitializer.initialize(localeContext, MapsInitializer.Renderer.LATEST, null)
MapsInitializer.initialize(context, MapsInitializer.Renderer.LATEST, null)
}
fun checkPlayServices(activity: Activity): Boolean {

View File

@@ -1,30 +1,85 @@
package net.vonforst.evmap.auto
import android.content.*
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.location.Location
import android.os.IBinder
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.validation.HostValidator
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.utils.checkAnyLocationPermission
import androidx.core.location.LocationListenerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import net.vonforst.evmap.R
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.checkFineLocationPermission
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
@ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
override fun onCreate() {
super.onCreate()
// we want to run as a foreground service to make sure we can use location
createNotificationChannel()
startForeground(NOTIFICATION_ID, getNotification())
}
private fun createNotificationChannel() {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
}
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.drawable.ic_appicon_notification)
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
@@ -40,49 +95,72 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
@ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver {
private val TAG = "EVMapSession"
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private var locationService: CarLocationService? = null
private val locationEngine: LocationEngine by lazy {
FusionEngine(carContext)
}
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) {
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
locationService = binder.service
locationService?.requestLocationUpdates()
}
override fun onServiceDisconnected(name: ComponentName?) {
locationService = null
}
private val prefs: PreferenceDataSource by lazy {
PreferenceDataSource(carContext)
}
private var serviceBound = false
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
return WelcomeScreen(carContext, this)
}
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
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?
updateLocation(location)
if (!prefs.dataSourceSet) {
screens.add(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE,
initialChoice = true,
extraDesc = R.string.data_sources_description
)
)
}
if (!locationPermissionGranted()) {
screens.add(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
)
}
if (screens.size > 1) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
for (i in 0 until screens.size - 1) {
screenManager.push(screens[i])
}
}
return screens.last()
}
private fun locationPermissionGranted() = carContext.checkFineLocationPermission()
private fun updateLocation(location: Location?) {
Log.d(TAG, "Received location: $location")
val mapScreen = mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
@@ -90,19 +168,24 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
this.location = location
}
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
unbindLocationService()
}
override fun onStart(owner: LifecycleOwner) {
requestLocationUpdates()
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
override fun onStop(owner: LifecycleOwner) {
removeLocationUpdates()
}
@SuppressLint("MissingPermission")
fun requestLocationUpdates() {
if (!locationPermissionGranted()) return
Log.i(TAG, "Requesting location updates")
requestCarHardwareLocationUpdates()
requestPhoneLocationUpdates()
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
@@ -111,40 +194,51 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
::onCarHardwareLocationReceived
)
}
serviceBound = cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
}
private val phoneLocationListener = LocationListenerCompat {
this.updateLocation(it)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun requestPhoneLocationUpdates() {
val location = locationEngine.getLastKnownLocation()
updateLocation(location)
locationEngine.requestLocationUpdates(
Priority.HIGH_ACCURACY,
1000,
phoneLocationListener
)
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun onStop() {
@SuppressLint("MissingPermission")
private fun removeLocationUpdates() {
if (!locationPermissionGranted()) return
removeCarHardwareLocationUpdates()
removePhoneLocationUpdates()
}
private fun removeCarHardwareLocationUpdates() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
}
unbindLocationService()
}
private fun unbindLocationService() {
locationService?.removeLocationUpdates()
if (serviceBound) {
cas.unbindService(serviceConnection)
serviceBound = false
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
private fun removePhoneLocationUpdates() {
locationEngine.removeUpdates(phoneLocationListener)
}
@SuppressLint("MissingPermission")
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
if (loc.location.status == CarValue.STATUS_SUCCESS && loc.location.value != null) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
removePhoneLocationUpdates()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun registerBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).registerReceiver(
locationReceiver,
IntentFilter(CarLocationService.ACTION_BROADCAST)
);
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun unregisterBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
}
}

View File

@@ -1,163 +0,0 @@
package net.vonforst.evmap.auto
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.gms.location.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
class CarLocationService : Service() {
private lateinit var serviceHandler: Handler
private lateinit var locationRequest: LocationRequest
private lateinit var notificationManager: NotificationManager
private lateinit var locationCallback: LocationCallback
private lateinit var fusedLocationClient: FusedLocationProviderClient
private val binder: IBinder = LocalBinder(this)
private var location: Location? = null
private val UPDATE_INTERVAL_IN_MILLISECONDS = 10000L
private val FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = UPDATE_INTERVAL_IN_MILLISECONDS / 2
private val CHANNEL_ID = "car_location"
private val NOTIFICATION_ID = 1000
private val TAG = "CarLocationService"
companion object {
const val ACTION_BROADCAST: String = BuildConfig.APPLICATION_ID + ".car_location_broadcast"
const val EXTRA_LOCATION: String = BuildConfig.APPLICATION_ID + ".location"
}
override fun onCreate() {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
super.onLocationResult(locationResult)
onNewLocation(locationResult.lastLocation)
}
}
createLocationRequest()
getLastLocation()
val handlerThread = HandlerThread(TAG)
handlerThread.start()
serviceHandler = Handler(handlerThread.looper)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = getString(R.string.app_name)
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT)
// Set the Notification Channel for the Notification Manager.
notificationManager.createNotificationChannel(mChannel)
}
startForeground(NOTIFICATION_ID, getNotification())
}
/**
* Returns the [NotificationCompat] used as part of the foreground service.
*/
private fun getNotification(): Notification {
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText(getString(R.string.auto_location_service))
.setContentTitle(getString(R.string.app_name))
.setOngoing(true)
.setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker(getString(R.string.auto_location_service))
.setWhen(System.currentTimeMillis())
return builder.build()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
private fun createLocationRequest() {
locationRequest = LocationRequest()
locationRequest.interval = UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.fastestInterval = FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}
private fun onNewLocation(location: Location) {
Log.i(TAG, "New location: $location")
this.location = location
// Notify anyone listening for broadcasts about the new location.
val intent = Intent(ACTION_BROADCAST)
intent.putExtra(EXTRA_LOCATION, location)
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
}
private fun getLastLocation() {
try {
fusedLocationClient.lastLocation
.addOnCompleteListener { task ->
if (task.isSuccessful && task.result != null) {
location = task.result
} else {
Log.w(TAG, "Failed to get location.")
}
}
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission.$unlikely")
}
}
/**
* Makes a request for location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun requestLocationUpdates() {
Log.i(TAG, "Requesting location updates")
startService(Intent(applicationContext, CarLocationService::class.java))
try {
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback, Looper.myLooper()
)
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not request updates. $unlikely")
}
}
/**
* Removes location updates. Note that in this sample we merely log the
* [SecurityException].
*/
fun removeLocationUpdates() {
Log.i(TAG, "Removing location updates")
try {
fusedLocationClient.removeLocationUpdates(locationCallback)
stopSelf()
} catch (unlikely: SecurityException) {
Log.e(TAG, "Lost location permission. Could not remove updates. $unlikely")
}
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i(TAG, "Service started")
// Tells the system to not try to recreate the service after it has been killed.
return START_NOT_STICKY
}
override fun onDestroy() {
serviceHandler.removeCallbacksAndMessages(null)
}
class LocalBinder(val service: CarLocationService) : Binder()
}

View File

@@ -0,0 +1,140 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.*
import androidx.car.app.hardware.info.CarSensors.UpdateRate
import net.vonforst.evmap.BuildConfig
import java.util.concurrent.Executor
/**
* CarSensors is not yet implemented for Android Automotive OS
* (see docs at https://developer.android.com/reference/androidx/car/app/hardware/info/CarSensors)
* so we provide our own implementation based on SensorManager APIs.
*/
val CarContext.patchedCarSensors: CarSensors
get() = if (BuildConfig.FLAVOR_automotive != "automotive") {
(this.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carSensors
} else {
CarSensorsWrapper(this)
}
class CarSensorsWrapper(carContext: CarContext) :
CarSensors {
private val sensorManager = carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val compassListeners: MutableMap<OnCarDataAvailableListener<Compass>, SensorEventListener> =
mutableMapOf()
override fun addAccelerometerListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Accelerometer>
) {
TODO("Not yet implemented")
}
override fun removeAccelerometerListener(listener: OnCarDataAvailableListener<Accelerometer>) {
TODO("Not yet implemented")
}
override fun addGyroscopeListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Gyroscope>
) {
TODO("Not yet implemented")
}
override fun removeGyroscopeListener(listener: OnCarDataAvailableListener<Gyroscope>) {
TODO("Not yet implemented")
}
override fun addCompassListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<Compass>
) {
val magSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
val accSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (magSensor == null) {
executor.execute {
listener.onCarDataAvailable(Compass(CarValue(null, 0, CarValue.STATUS_UNAVAILABLE)))
}
return
}
val sensorListener = object : SensorEventListener {
var magValues: FloatArray? = null
// AAOS cars may not provide an acceleration sensor, so we assume acceleration based on
// Earth's gravity. May not be correct when driving on other planets.
var accValues = floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)
val rotMatrix = FloatArray(9)
val orientation = FloatArray(3)
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor) {
magSensor -> magValues = event.values
accSensor -> accValues = event.values
}
if (magValues == null) return
SensorManager.getRotationMatrix(rotMatrix, null, accValues, magValues)
SensorManager.getOrientation(rotMatrix, orientation)
val compassDegrees = orientation.map { Math.toDegrees(it.toDouble()).toFloat() }
executor.execute {
listener.onCarDataAvailable(
Compass(
CarValue(
compassDegrees,
event.timestamp,
CarValue.STATUS_SUCCESS
)
)
)
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
}
}
compassListeners[listener] = sensorListener
sensorManager.registerListener(sensorListener, magSensor, mapRate(rate))
accSensor?.let { sensorManager.registerListener(sensorListener, it, mapRate(rate)) }
}
private fun mapRate(@UpdateRate rate: Int): Int {
return when (rate) {
CarSensors.UPDATE_RATE_NORMAL -> SensorManager.SENSOR_DELAY_NORMAL
CarSensors.UPDATE_RATE_UI -> SensorManager.SENSOR_DELAY_UI
CarSensors.UPDATE_RATE_FASTEST -> SensorManager.SENSOR_DELAY_FASTEST
else -> throw IllegalArgumentException()
}
}
override fun removeCompassListener(listener: OnCarDataAvailableListener<Compass>) {
compassListeners[listener]?.let {
sensorManager.unregisterListener(it)
}
}
override fun addCarHardwareLocationListener(
rate: Int,
executor: Executor,
listener: OnCarDataAvailableListener<CarHardwareLocation>
) {
TODO("Not yet implemented")
}
override fun removeCarHardwareLocationListener(listener: OnCarDataAvailableListener<CarHardwareLocation>) {
TODO("Not yet implemented")
}
}

View File

@@ -15,29 +15,40 @@ import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonBuffer
import moe.banana.jsonapi2.ResourceIdentifier
import net.vonforst.evmap.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import java.io.IOException
import kotlin.math.roundToInt
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))
ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private var chargepoint: Chargepoint? = null
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
@@ -59,7 +70,24 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
setSingleList(ItemList.Builder().apply {
val header = meta?.let { meta ->
chargepoint?.let { chargepoint ->
"${
nameForPlugType(
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower()} " + carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
}
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val list = ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
@@ -67,9 +95,17 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
if (carContext.carAppApiLevel >= 5) {
setEnabled(myTariffsAll || myTariffs != null && price.tariffId in myTariffs)
}
}.build())
}
}.build())
}.build()
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
setActionStrip(
ActionStrip.Builder().addAction(
@@ -94,15 +130,18 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
.build().intent
intent.data =
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
Uri.parse(ChargepriceApi.getPoiUrl(charger))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
if (BuildConfig.FLAVOR_automotive != "automotive") {
// only show the toast "opened on phone" if we're running on a phone
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
@@ -125,19 +164,21 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun formatPrice(price: ChargePrice): String {
val amount = price.chargepointPrices.first().price
?: return "${carContext.getString(R.string.chargeprice_price_not_available)} (${price.chargepointPrices.first().noPriceReason})"
val totalPrice = carContext.getString(
R.string.charge_price_format,
price.chargepointPrices.first().price,
amount,
currency(price.currency)
)
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
val kwhPrice = if (amount > 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,
amount / meta!!.energy,
currency(price.currency)
)
} else null
@@ -169,59 +210,76 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private fun loadPrices(model: Model?) {
val dataAdapter = getDataAdapter() ?: return
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
val manufacturer = model?.manufacturer?.value
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
val result = api.getChargePrices(ChargepriceRequest().apply {
this.dataAdapter = dataAdapter
station = cpStation
vehicle = HasOne(car)
tariffs = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
HasMany<ChargepriceTariff>(*myTariffs.map {
ResourceIdentifier(
"tariff",
it
if (cpStation.chargePoints.isEmpty()) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
Relationships(
"tariffs" to Relationship.ToMany(
myTariffs.map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
}.toTypedArray()).apply {
meta = JsonBuffer.create(
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
)
}
} else null
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency
)
}, ChargepriceApi.getChargepriceLanguage())
} else null
), 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 }
// choose the highest power chargepoint
// (we have already filtered so that only compatible ones are included)
val chargepoint = cpStation.chargePoints.maxByOrNull { it.power }
val index = cpStation.chargePoints.indexOf(chargepoint)
this@ChargepriceScreen.chargepoint =
charger.chargepoints.filter { equivalentPlugTypes(it.type).any { it in car.compatibleEvmapConnectors } }[index]
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 metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
@@ -229,15 +287,15 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
if (filteredPrices.isEmpty()) {
null
} else {
cp.clone().apply {
cp.copy(
chargepointPrices = filteredPrices
}
)
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariff?.get()?.id in myTariffs
myTariffs != null && it.tariffId in myTariffs
}
invalidate()
} catch (e: IOException) {
@@ -315,10 +373,4 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
return vehicles[0]
}
private fun getDataAdapter(): String? = when (charger.dataSource) {
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
else -> null
}
}

View File

@@ -1,10 +1,10 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.*
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
@@ -19,6 +19,7 @@ import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
@@ -26,21 +27,27 @@ 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.iconForPlugType
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Cost
import net.vonforst.evmap.model.FaultReport
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.getReferenceData
import net.vonforst.evmap.viewmodel.awaitFinished
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
@@ -48,17 +55,14 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val imageSize = 128 // images should be 128dp according to docs
private val imageHeightLarge = 480 // images should be 480 x 854 dp according to docs
private val imageWidthLarge = 854
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
private val iconGen =
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
@@ -66,10 +70,11 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
private var favorite: Favorite? = null
private var favoriteUpdateJob: Job? = null
init {
referenceData.observe(this) {
loadCharger()
}
loadCharger()
}
override fun onGetTemplate(): Template {
@@ -78,8 +83,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
if (largeImageSupported && photo != null) {
setImage(CarIcon.Builder(IconCompat.createWithBitmap(photo)).build())
if (largeImageSupported) {
photo?.let {
setImage(CarIcon.Builder(IconCompat.createWithBitmap(it)).build())
}
}
generateRows(charger).forEach { addRow(it) }
addAction(
@@ -93,59 +100,114 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.setFlags(Action.FLAG_PRIMARY)
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener {
navigateToCharger(charger)
}
.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))
if (ChargepriceApi.isChargerSupported(charger)) {
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()
)
charger?.let { charger ->
setActionStrip(
ActionStrip.Builder().apply {
if (BuildConfig.FLAVOR_automotive != "automotive") {
// show "Open in app" action if not running on Android Automotive
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()
)
}
// show fav action
addAction(Action.Builder()
.setOnClickListener {
favorite?.let {
deleteFavorite(it)
} ?: run {
insertFavorite(charger)
}
}
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (favorite != null) {
R.drawable.ic_fav
} else {
R.drawable.ic_fav_no
}
)
)
.setTint(CarColor.DEFAULT).build()
)
.build())
.build()
}.build()
)
}
}.build()
}
private fun insertFavorite(charger: ChargeLocation) {
if (favoriteUpdateJob?.isCompleted == false) return
favoriteUpdateJob = lifecycleScope.launch {
db.chargeLocationsDao().insert(charger)
val fav = Favorite(
chargerId = charger.id,
chargerDataSource = charger.dataSource
)
val id = db.favoritesDao().insert(fav)[0]
favorite = fav.copy(favoriteId = id)
invalidate()
}
}
private fun deleteFavorite(fav: Favorite) {
if (favoriteUpdateJob?.isCompleted == false) return
favoriteUpdateJob = lifecycleScope.launch {
db.favoritesDao().delete(fav)
favorite = null
invalidate()
}
}
private fun generateRows(charger: ChargeLocation): List<Row> {
val rows = mutableListOf<Row>()
val photo = photo
// Row 1: address + chargepoints
rows.add(Row.Builder().apply {
@@ -183,15 +245,9 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.cost?.let { addText(generateCostStatusText(it)) }
charger.faultReport?.let { fault ->
addText(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
addText(generateFaultReportTitle(fault))
}
}.build())
} else {
@@ -206,20 +262,14 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val operatorText = generateOperatorText(charger)
setTitle(operatorText)
charger.cost?.let {
addText(it.getStatusText(carContext, emoji = true))
(it.descriptionShort ?: it.descriptionLong)?.let { addText(it) }
addText(generateCostStatusText(it))
it.getDetailText()?.let { addText(it) }
}
}.build())
// row 3: fault report (if exists)
charger.faultReport?.let { fault ->
rows.add(Row.Builder().apply {
setTitle(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
setTitle(generateFaultReportTitle(fault))
fault.description?.let {
addText(
HtmlCompat.fromHtml(
@@ -232,8 +282,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
// row 4: opening hours + location description
charger.openinghours?.let { hours ->
val title =
hours.getStatusText(carContext).ifEmpty { carContext.getString(R.string.hours) }
rows.add(Row.Builder().apply {
setTitle(hours.getStatusText(carContext))
setTitle(title)
hours.description?.let { addText(it) }
charger.locationDescription?.let { addText(it) }
}.build())
@@ -242,18 +294,79 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
return rows
}
private fun generateCostStatusText(cost: Cost): CharSequence {
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
// replace emoji with CarIcon
string.indexOf('⚡').takeIf { it >= 0 }?.let { index ->
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_lightning
)
).setTint(CarColor.YELLOW).build()
), index, index + 1, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
}
string.indexOf('\uD83C').takeIf { it >= 0 }?.let { index ->
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_parking
)
).setTint(CarColor.BLUE).build()
), index, index + 2, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
}
return string
}
private fun generateFaultReportTitle(fault: FaultReport): CharSequence {
val string = SpannableString(
carContext.getString(
R.string.auto_fault_report_date,
fault.created?.atZone(ZoneId.systemDefault())
?.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
// replace emoji with CarIcon
string.setSpan(
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fault_report
)
).setTint(CarColor.YELLOW).build()
), 0, 1, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE
)
return string
}
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
} ${cp.formatPower()}"
)
"${cp.count}× "
).append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
iconForPlugType(cp.type)
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
).append(" ").append(cp.formatPower())
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
@@ -293,25 +406,23 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
}
private fun loadCharger() {
val referenceData = referenceData.value ?: return
lifecycleScope.launch {
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
favorite = db.favoritesDao().findFavorite(chargerSparse.id, chargerSparse.dataSource)
val response = repo.getChargepointDetail(chargerSparse.id).awaitFinished()
if (response.status == Status.SUCCESS) {
val charger = response.data!!
this@ChargerDetailScreen.charger = charger
invalidate()
val photo = charger.photos?.firstOrNull()
photo?.let {
val density = carContext.resources.displayMetrics.density
val url = if (largeImageSupported) {
photo.getUrl(
width = (imageWidthLarge * density).roundToInt(),
height = (imageHeightLarge * density).roundToInt()
)
} else {
photo.getUrl(size = (imageSize * density).roundToInt())
}
val size =
(density * if (largeImageSupported) imageSizeLarge else imageSize).roundToInt()
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
var img =
val img =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
// draw icon on top of image
@@ -321,21 +432,32 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
multi = charger.isMulti()
)
img = img.copy(Bitmap.Config.ARGB_8888, true)
val outImg = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val iconSmall = icon.scale(
(img.height * 0.4 / icon.height * icon.width).roundToInt(),
(img.height * 0.4).roundToInt()
(size * 0.4 / icon.height * icon.width).roundToInt(),
(size * 0.4).roundToInt()
)
val canvas = Canvas(outImg)
val m = Matrix()
m.setRectToRect(
RectF(0f, 0f, img.width.toFloat(), img.height.toFloat()),
RectF(0f, 0f, size.toFloat(), size.toFloat()),
Matrix.ScaleToFit.CENTER
)
canvas.drawBitmap(
img.copy(Bitmap.Config.ARGB_8888, false), m, null
)
val canvas = Canvas(img)
canvas.drawBitmap(
iconSmall,
0f,
(img.height - iconSmall.height * 1.1).toFloat(),
(size - iconSmall.height * 1.1).toFloat(),
null
)
this@ChargerDetailScreen.photo = img
this@ChargerDetailScreen.photo = outImg
}
this@ChargerDetailScreen.charger = charger
invalidate()
availability = getAvailability(charger).data

View File

@@ -1,93 +1,590 @@
package net.vonforst.evmap.auto
import android.graphics.Bitmap
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.map
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.FilterViewModel
import kotlin.math.roundToInt
class FilterScreen(ctx: CarContext) : Screen(ctx) {
@androidx.car.app.annotations.ExperimentalCarApi
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
private val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = 6
private val checkIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
private val emptyIcon: CarIcon
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
).asCarIcon()
}
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var page = 0
init {
filterProfiles.observe(this) {
val filterStatus = prefs.filterStatus
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
page = 0
} else {
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
}
invalidate()
}
}
override fun onGetTemplate(): Template {
val filterStatus =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
val filterStatus = prefs.filterStatus
return ListTemplate.Builder().apply {
var title = carContext.getString(R.string.menu_filter)
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
val paginatedProfiles = paginateProfiles(it)
setSingleList(buildFilterProfilesList(paginatedProfiles, filterStatus))
val numPages = paginatedProfiles.size
if (numPages > 1) {
title += " " + carContext.getString(
R.string.auto_multipage,
page + 1,
numPages
)
}
} ?: setLoading(true)
setTitle(carContext.getString(R.string.menu_filter))
setTitle(title)
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().apply {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_edit
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
lifecycleScope.launch {
db.filterValueDao()
.copyFiltersToCustom(filterStatus, prefs.dataSource)
screenManager.push(EditFiltersScreen(carContext))
}
})
}.build())
}.build()
)
}.build()
}
private fun paginateProfiles(filterProfiles: List<FilterProfile>): List<List<FilterProfile>> {
val filterStatus = prefs.filterStatus
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
return filterProfiles.paginate(
maxRows - extraRows,
maxRows - extraRows - 1,
maxRows - 2,
maxRows - 1
)
}
private fun buildFilterProfilesList(
paginatedProfiles: List<List<FilterProfile>>,
filterStatus: Long
): ItemList {
return ItemList.Builder().apply {
if (page > 0) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_back
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page -= 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
}
}.build())
}
if (page == 0) {
addItem(Row.Builder().apply {
val active = filterStatus == FILTERS_DISABLED
setTitle(carContext.getString(R.string.no_filters))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_close
)
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
.build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_DISABLED) }
}.build())
addItem(Row.Builder().apply {
val active = filterStatus == FILTERS_FAVORITES
setTitle(carContext.getString(R.string.filter_favorites))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
.build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_FAVORITES) }
}.build())
if (FILTERS_CUSTOM == filterStatus) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_custom))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_checkbox_checked
)
).setTint(CarColor.PRIMARY).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(FILTERS_CUSTOM) }
}.build())
}
}
paginatedProfiles[page].forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
val active = filterStatus == it.id
setTitle(name)
setImage(
if (active)
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_check
)
).setTint(CarColor.SECONDARY).build() else emptyCarIcon,
Row.IMAGE_TYPE_ICON
)
setOnClickListener { onItemClick(it.id) }
}.build())
}
if (page < paginatedProfiles.size - 1) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page + 2)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_forward
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page += 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
}
}.build())
}
}.build()
}
private fun onItemClick(id: Long) {
prefs.filterStatus = id
screenManager.pop()
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
private val vm = FilterViewModel(carContext.applicationContext as Application)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var page = 0
private var paginatedFilters = vm.filtersWithValue.map {
it?.paginate(maxRows, maxRows - 1, maxRows - 2, maxRows - 1)
}
init {
paginatedFilters.observe(this) {
vm.filterProfile.observe(this) {
invalidate()
}
}
}
override fun onGetTemplate(): Template {
val currentProfileName = vm.filterProfile.value?.name
return ListTemplate.Builder().apply {
paginatedFilters.value?.let { paginatedFilters ->
setSingleList(buildFiltersList(paginatedFilters))
} ?: setLoading(true)
var title = currentProfileName?.let {
carContext.getString(
R.string.edit_filter_profile,
it,
)
} ?: carContext.getString(R.string.menu_filter)
val numPages = paginatedFilters.value?.size ?: 0
if (numPages > 1) {
title += " " + carContext.getString(
R.string.auto_multipage,
page + 1,
numPages
)
}
setTitle(title)
setHeaderAction(Action.BACK)
setActionStrip(ActionStrip.Builder().apply {
val currentProfile = vm.filterProfile.value
if (currentProfile != null) {
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_delete
)
).build()
)
setOnClickListener {
lifecycleScope.launch {
vm.deleteCurrentProfile()
CarToast.makeText(
carContext,
carContext.getString(
R.string.deleted_filterprofile,
currentProfile.name
),
CarToast.LENGTH_SHORT
).show()
invalidate()
screenManager.pop()
}
}
}.build())
}
addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_save
)
).build()
)
.setOnClickListener {
val textPromptScreen = TextPromptScreen(
carContext,
R.string.save_as_profile,
R.string.save_profile_enter_name,
currentProfileName
)
screenManager.pushForResult(textPromptScreen) { name ->
if (name == null) return@pushForResult
var saveSuccess = false
lifecycleScope.launch {
saveSuccess = vm.saveAsProfile(name as String)
screenManager.popTo(MapScreen.MARKER)
}
if (!saveSuccess) return@pushForResult
}
invalidate()
}
.build()
)
}
.build())
}.build()
}
private fun buildFiltersList(paginatedFilters: List<FilterValues>): ItemList {
return ItemList.Builder().apply {
if (page > 0) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_back
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page -= 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
}
}.build())
}
paginatedFilters[page].forEach {
val filter = it.filter
val value = it.value
addItem(Row.Builder().apply {
setTitle(filter.name)
when (filter) {
is BooleanFilter -> {
setToggle(Toggle.Builder {
(value as BooleanFilterValue).value = it
lifecycleScope.launch { vm.saveFilterValues() }
}.setChecked((value as BooleanFilterValue).value).build())
}
is MultipleChoiceFilter -> {
setBrowsable(true)
setOnClickListener {
screenManager.pushForResult(
MultipleChoiceFilterScreen(
carContext,
filter,
value as MultipleChoiceFilterValue
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
addText(
if ((value as MultipleChoiceFilterValue).all) {
carContext.getString(R.string.all_selected)
} else {
carContext.getString(
R.string.number_selected,
value.values.size
)
}
)
}
is SliderFilter -> {
setBrowsable(true)
addText((value as SliderFilterValue).value.toString() + " " + filter.unit)
setOnClickListener {
screenManager.pushForResult(
SliderFilterScreen(
carContext,
filter,
value
)
) {
lifecycleScope.launch { vm.saveFilterValues() }
}
}
}
}
}.build())
}
if (page < paginatedFilters.size - 1) {
addItem(Row.Builder().apply {
setTitle(
CarText.Builder(
carContext.getString(R.string.auto_multipage_goto, page + 2)
).build()
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_arrow_forward
)
).build(),
Row.IMAGE_TYPE_ICON
)
setOnClickListener {
page += 1
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
}
}.build())
}
}.build()
}
}
class MultipleChoiceFilterScreen(
ctx: CarContext,
val filter: MultipleChoiceFilter,
val value: MultipleChoiceFilterValue
) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: Pair<String, String>): Boolean =
value.all || value.values.contains(it.first)
override fun toggleSelected(item: Pair<String, String>) {
if (isSelected(item)) {
val values = if (value.all) filter.choices.keys else value.values
value.values = values.minus(item.first).toMutableSet()
value.all = false
} else {
value.values.add(item.first)
if (value.values == filter.choices.keys) {
value.all = true
}
}
}
override fun selectAll() {
value.all = true
super.selectAll()
}
override fun selectNone() {
value.all = false
value.values = mutableSetOf()
super.selectNone()
}
override fun getLabel(it: Pair<String, String>): String = it.second
override suspend fun loadData(): List<Pair<String, String>> {
return filter.choices.entries.map { it.toPair() }
}
}
class SliderFilterScreen(
ctx: CarContext,
val filter: SliderFilter,
val value: SliderFilterValue
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(
Pane.Builder().apply {
addRow(Row.Builder().apply {
setTitle(filter.name)
addText(value.value.toString() + " " + filter.unit)
addText(generateSlider())
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener(::decrease)
}.build())
addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener(::increase)
}.build())
}.build()
).apply {
setHeaderAction(Action.BACK)
}.build()
}
private fun buildFilterProfilesList(
profiles: List<FilterProfile>,
filterStatus: Long
): ItemList {
return ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
if (FILTERS_DISABLED == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = FILTERS_DISABLED
screenManager.pop()
}
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
setTitle(name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = it.id
screenManager.pop()
}
}.build())
}
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
}.build()
private fun generateSlider(): CharSequence {
val bar = ""
val dot = ""
val length = 30
val position =
((filter.inverseMapping(value.value) - filter.min) / (filter.max - filter.min).toDouble() * length).roundToInt()
val text = SpannableStringBuilder()
text.append(
bar.repeat(position),
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(
dot,
ForegroundCarColorSpan.create(CarColor.SECONDARY),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.append(bar.repeat(length - position))
return text
}
private fun increase() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal < filter.max) valueInternal += 1
value.value = filter.mapping(valueInternal)
invalidate()
}
private fun decrease() {
var valueInternal = filter.inverseMapping(value.value)
if (valueInternal > filter.min) valueInternal -= 1
value.value = filter.mapping(valueInternal)
invalidate()
}
}

View File

@@ -2,171 +2,275 @@ package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.model.FilterValue
import net.vonforst.evmap.model.FilterWithValue
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.ChargeLocationsRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.bearingBetween
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.utils.headingDiff
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.awaitFinished
import net.vonforst.evmap.viewmodel.filtersWithValue
import net.vonforst.evmap.viewmodel.getFilterValues
import net.vonforst.evmap.viewmodel.getFilters
import net.vonforst.evmap.viewmodel.getReferenceData
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites
*/
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession) :
Screen(ctx), LocationAwareScreen, OnContentRefreshListener,
ItemList.OnItemVisibilityChangedListener, DefaultLifecycleObserver {
companion object {
val MARKER = "map"
}
/* 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 updateCoroutine: Job? = null
private var availabilityUpdateCoroutine: Job? = null
private var visibleStart: Int? = null
private var visibleEnd: Int? = null
private var location: Location? = null
private var lastChargerUpdateLocation: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private var loadingError = false
private var locationError = false
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val repo =
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
private val searchRadius = 5 // kilometers
private val chargerUpdateThreshold = 2000 // meters
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
HashMap()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
min(
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST),
25
)
} else 6
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters = api.getFilters(referenceData, carContext.stringProvider())
private val filtersWithValue = filtersWithValue(filters, filterValues)
private var filterStatus = prefs.filterStatus
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private val carInfo: CarInfo by lazy {
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
}
private val carSensors: CarSensors by lazy { carContext.patchedCarSensors }
private var energyLevel: EnergyLevel? = null
private var heading: Compass? = null
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
private var searchLocation: LatLng? = null
init {
filtersWithValue.observe(this) {
loadChargers()
}
lifecycle.addObserver(this)
marker = MARKER
}
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
prefs.placeSearchResultAndroidAutoName?.let {
carContext.getString(R.string.auto_chargers_near_location, it)
} ?: carContext.getString(
if (filterStatus == FILTERS_FAVORITES) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
} ?: setLoading(true)
if (prefs.placeSearchResultAndroidAutoName != null) {
searchLocation?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).apply {
if (prefs.placeSearchResultAndroidAutoName != null) {
setMarker(
PlaceMarker.Builder()
.setColor(CarColor.PRIMARY)
.build()
)
}
}.build())
}
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
}
}
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address.city }.distinct().size > 1
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
if (filterStatus == FILTERS_FAVORITES) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
builder.setOnItemsVisibilityChangedListener(this@MapScreen)
setItemList(builder.build())
} ?: setLoading(true)
} ?: run {
if (loadingError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.connection_error)
)
setItemList(builder.build())
} else if (locationError) {
val builder = ItemList.Builder()
builder.setNoItemsMessage(
carContext.getString(R.string.location_error)
)
setItemList(builder.build())
} else {
setLoading(true)
}
}
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
if (!favorites) {
val filtersCount = filtersWithValue.value?.count {
setHeaderAction(Action.APP_ICON)
val filtersCount = if (filterStatus == FILTERS_FAVORITES) 1 else {
filtersWithValue?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings
)
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
numUpdates = 0
filterStatus.value =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
}
screenManager.push(SettingsScreen(carContext))
session.mapScreen = null
}
.build())
.addAction(Action.Builder().apply {
setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
if (prefs.placeSearchResultAndroidAuto != null) {
R.drawable.ic_search_off
} else {
R.drawable.ic_search
}
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
loadChargers()
}
} else {
screenManager.pushForResult(
PlaceSearchScreen(
carContext,
session
)
) {
chargers = null
loadChargers()
}
session.mapScreen = null
}
})
}.build())
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.push(FilterScreen(carContext, session))
session.mapScreen = null
}
.build())
.build())
if (carContext.carAppApiLevel >= 5) {
setOnContentRefreshListener(this@MapScreen)
}
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if (charger.maxPower > 100) {
val markerTint = if ((charger.maxPower ?: 0.0) > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
getMarkerTint(charger)
@@ -184,7 +288,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
@@ -214,8 +318,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
}
// power
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
val power = charger.maxPower;
if (power != null) {
if (text.isNotEmpty()) text.append(" · ")
text.append("${power.roundToInt()} kW")
}
// availability
availabilities[charger.id]?.second?.let { av ->
@@ -240,6 +347,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
session.mapScreen = null
}
}.build()
}
@@ -250,10 +358,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
) {
return
}
val previousLocation = this.location
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
if (previousLocation == null) {
loadChargers()
}
val now = Instant.now()
@@ -264,120 +372,216 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
// update displayed distances
invalidate()
}
if (lastChargerUpdateLocation == null ||
location.distanceTo(lastChargerUpdateLocation) > chargerUpdateThreshold
) {
lastChargerUpdateLocation = location
// update displayed chargers
loadChargers()
}
}
private fun loadChargers() {
val location = location ?: return
val referenceData = referenceData.value ?: return
val filters = filtersWithValue.value ?: return
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()*/
return
}
val searchLocation =
prefs.placeSearchResultAndroidAuto ?: LatLng.fromLocation(location)
this.searchLocation = searchLocation
updateCoroutine = lifecycleScope.launch {
loadingError = false
try {
filterStatus = prefs.filterStatus
val filterValues =
db.filterValueDao().getFilterValuesAsync(filterStatus, prefs.dataSource)
val filters = repo.getFiltersAsync(carContext.stringProvider())
filtersWithValue = filtersWithValue(filters, filterValues)
// load chargers
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} else {
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
filters
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < maxRows) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius * 10,
zoom = 16f,
filters
if (filterStatus == FILTERS_FAVORITES) {
chargers =
db.favoritesDao().getAllFavoritesAsync().map { it.charger }.sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
} else {
// try multiple search radii until we have enough chargers
var chargers: List<ChargeLocation>? = null
for (radius in listOf(searchRadius, searchRadius * 10, searchRadius * 50)) {
val response = repo.getChargepointsRadius(
searchLocation,
radius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
loadingError = true
invalidate()
return@launch
}
chargers = headingFilter(
response.data?.filterIsInstance(ChargeLocation::class.java),
searchLocation
)
if (chargers == null || chargers.size >= maxRows) {
break
}
}
this@MapScreen.chargers = chargers
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
updateCoroutine = null
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
loadingError = true
invalidate()
}
}
}
/**
* Filters by heading if heading available and enabled
*/
private fun headingFilter(
chargers: List<ChargeLocation>?,
searchLocation: LatLng
): List<ChargeLocation>? =
heading?.orientations?.value?.get(0)?.let { heading ->
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
chargers?.filter {
val bearing = bearingBetween(
searchLocation.latitude,
searchLocation.longitude,
it.coordinates.lat,
it.coordinates.lng
)
val diff = headingDiff(bearing, heading.toDouble())
abs(diff) < 30
}
} ?: chargers
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
invalidate()
if (isUpdate) invalidate()
}
private fun onCompassUpdated(compass: Compass) {
this.heading = compass
}
override fun onStart(owner: LifecycleOwner) {
setupListeners()
session.requestLocationUpdates()
locationError = false
Handler(Looper.getMainLooper()).postDelayed({
if (location == null) {
locationError = true
invalidate()
}
}, 5000)
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
if (prefs.dataSource != repo.api.value?.id) {
repo.api.value = createApi(prefs.dataSource, carContext)
}
invalidate()
loadChargers()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (ContextCompat.checkSelfPermission(
carContext,
"com.google.android.gms.permission.CAR_FUEL"
) != PackageManager.PERMISSION_GRANTED
)
val exec = ContextCompat.getMainExecutor(carContext)
if (supportsCarApiLevel3(carContext)) {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
}
if (!permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
})
return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
override fun onStop(owner: LifecycleOwner) {
// Reloading chargers in onStart does not seem to count towards content limit.
// So let's do this so the user gets fresh chargers when re-entering the app.
// Deleting the data already in onStop makes sure that we show a loading screen directly
// (i.e. onGetTemplate is not called while the old data is still there)
chargers = null
availabilities.clear()
location = null
removeListeners()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
}
override fun onContentRefreshRequested() {
loadChargers()
availabilities.clear()
val start = visibleStart
val end = visibleEnd
if (start != null && end != null) {
onItemVisibilityChanged(start, end)
}
}
override fun onItemVisibilityChanged(startIndex: Int, endIndex: Int) {
// when the list is scrolled, load corresponding availabilities
if (startIndex == visibleStart && endIndex == visibleEnd && !availabilities.isEmpty()) return
if (startIndex == -1 || endIndex == -1) return
if (availabilityUpdateCoroutine != null) return
visibleEnd = endIndex
visibleStart = startIndex
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) <= availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
availabilityUpdateCoroutine = lifecycleScope.launch {
delay(300L)
val chargers = chargers ?: return@launch
if (chargers.isEmpty()) return@launch
val tasks = chargers.subList(
min(startIndex, chargers.size - 1),
min(endIndex, chargers.size - 1)
).mapNotNull {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
lifecycleScope.async {
val availability = getAvailability(it).data
val date = ZonedDateTime.now()
availabilities[it.id] = date to availability
}
} else null
}
if (tasks.isNotEmpty()) {
tasks.awaitAll()
invalidate()
}
availabilityUpdateCoroutine = null
}
}
}

View File

@@ -12,7 +12,8 @@ import net.vonforst.evmap.R
class PermissionScreen(
ctx: CarContext,
@StringRes val message: Int,
val permissions: List<String>
val permissions: List<String>,
val finishApp: Boolean = true
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(message))
@@ -31,7 +32,13 @@ class PermissionScreen(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
if (finishApp) {
carContext.finishCarApp()
} else {
// pop twice to get away from the screen that requires the permission
screenManager.pop()
screenManager.pop()
}
}
.build(),
)

View File

@@ -0,0 +1,234 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.iconForPlaceType
import net.vonforst.evmap.adapter.isSpecialPlace
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import java.io.IOException
import java.time.Instant
@ExperimentalCarApi
class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
SearchTemplate.SearchCallback, LocationAwareScreen,
DefaultLifecycleObserver {
private val hardwareMan: CarHardwareManager by lazy {
ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private var resultList: List<AutocompletePlace>? = null
private var recentResults = mutableListOf<RecentAutocompletePlace>()
private var currentProvider: AutocompleteProvider? = null
private val providers = getAutocompleteProviders(ctx)
private val recents = AppDatabase.getInstance(ctx).recentAutocompletePlaceDao()
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var location: Location? = null
private var energyLevel: EnergyLevel? = null
private var updateJob: Job? = null
private val prefs = PreferenceDataSource(ctx)
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL"
)
}
init {
lifecycle.addObserver(this)
update("")
}
override fun onGetTemplate(): Template {
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
setSearchHint(carContext.getString(R.string.search))
resultList?.let {
setItemList(buildItemList(it))
} ?: setLoading(true)
}.build()
}
private fun buildItemList(results: List<AutocompletePlace>): ItemList {
return ItemList.Builder().apply {
results.forEach { place ->
addItem(Row.Builder().apply {
setTitle(place.primaryText)
addText(place.secondaryText)
val icon = iconForPlaceType(place.types)
setImage(
CarIcon.Builder(IconCompat.createWithResource(carContext, icon))
.setTint(if (isSpecialPlace(place.types)) CarColor.PRIMARY else CarColor.DEFAULT)
.build()
)
// distance
place.distanceMeters?.let {
val text = SpannableStringBuilder()
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
it,
energyLevel?.distanceDisplayUnit?.value
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
addText(text)
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
}
}
}.build())
}
}.build()
}
override fun onSearchTextChanged(searchText: String) {
update(searchText)
}
override fun onSearchSubmitted(searchText: String) {
update(searchText)
}
private fun update(searchText: String) {
updateJob?.cancel()
updateJob = lifecycleScope.launch {
if (prefs.searchProvider == "mapbox" && !isShortQuery(searchText)) {
delay(500L)
}
try {
loadNewList(searchText)
} catch (e: IOException) {
CarToast.makeText(
carContext,
R.string.autocomplete_connection_error,
CarToast.LENGTH_SHORT
).show()
}
}
}
private suspend fun loadNewList(query: String) {
for (provider in providers) {
try {
recentResults.clear()
currentProvider = provider
// first search in recent places
val recentPlaces = if (query.isEmpty()) {
recents.getAllAsync(provider.id, limit = maxItems)
} else {
recents.searchAsync(query, provider.id, limit = maxItems)
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
invalidate()
// if we already have enough results or the query is short, stop here
if (isShortQuery(query) || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
}
private fun isShortQuery(query: CharSequence) = query.length < 3
override fun updateLocation(location: Location) {
this.location = location
}
override fun onResume(owner: LifecycleOwner) {
session.requestLocationUpdates()
session.mapScreen = this
if (supportsCarApiLevel3(carContext) && permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}) {
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
val isUpdate = this.energyLevel == null
this.energyLevel = energyLevel
if (isUpdate) invalidate()
}
override fun onPause(owner: LifecycleOwner) {
session.mapScreen = null
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}
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
}
}

View File

@@ -0,0 +1,136 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
SearchTemplate.SearchCallback {
protected var fullList: List<T>? = null
private var currentList: List<T> = emptyList()
private var query: String = ""
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean
override fun onGetTemplate(): Template {
if (fullList == null) {
lifecycleScope.launch {
fullList = loadData()
filterList()
invalidate()
}
}
return SearchTemplate.Builder(this).apply {
setHeaderAction(Action.BACK)
fullList?.let {
setItemList(buildItemList())
} ?: run {
setLoading(true)
}
if (isMultiSelect) {
setActionStrip(ActionStrip.Builder().apply {
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_all
)
).build()
).setOnClickListener(::selectAll).build()
)
addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_select_none
)
).build()
).setOnClickListener(::selectNone).build()
)
}.build())
}
}.build()
}
private fun filterList() {
currentList = fullList?.let {
it.sortedBy { getLabel(it).lowercase() }
.sortedBy { !isSelected(it) }
.filter { getLabel(it).lowercase().contains(query.lowercase()) }
.take(maxRows)
} ?: emptyList()
}
private val checkedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_checked))
.setTint(CarColor.PRIMARY)
.build()
private val uncheckedIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_checkbox_unchecked))
.setTint(CarColor.PRIMARY)
.build()
private fun buildItemList(): ItemList {
return ItemList.Builder().apply {
currentList.forEach { item ->
addItem(
Row.Builder()
.setTitle(getLabel(item))
.setImage(if (isSelected(item)) checkedIcon else uncheckedIcon)
.setOnClickListener {
toggleSelected(item)
if (isMultiSelect) {
invalidate()
} else {
setResult(item)
screenManager.pop()
}
}
.build()
)
}
}.build()
}
override fun onSearchTextChanged(searchText: String) {
query = searchText
filterList()
invalidate()
}
override fun onSearchSubmitted(searchText: String) {
query = searchText
filterList()
invalidate()
}
abstract fun toggleSelected(item: T)
open fun selectAll() {
CarToast.makeText(carContext, R.string.selecting_all, CarToast.LENGTH_SHORT).show()
invalidate()
}
open fun selectNone() {
CarToast.makeText(carContext, R.string.selecting_none, CarToast.LENGTH_SHORT).show()
invalidate()
}
abstract fun isSelected(it: T): Boolean
abstract fun getLabel(it: T): String
abstract suspend fun loadData(): List<T>
}

View File

@@ -0,0 +1,559 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
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 net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
import kotlin.math.min
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.auto_settings))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_settings_data_source
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(DataSettingsScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(ChargepriceSettingsScreen(carContext))
}
}.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 {
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.build()
)
}
}.build())
}.build()
}
}
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
val db = AppDatabase.getInstance(ctx)
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
val dataSourceValues = carContext.resources.getStringArray(R.array.pref_data_source_values)
val searchProviderNames =
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
carContext.resources.getStringArray(R.array.pref_search_provider_values)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_data_sources))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_data_source))
setBrowsable(true)
val dataSourceId = prefs.dataSource
val dataSourceDesc = dataSourceNames[dataSourceValues.indexOf(dataSourceId)]
addText(dataSourceDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE
)
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_provider))
setBrowsable(true)
val searchProviderId = prefs.searchProvider
val searchProviderDesc =
searchProviderNames[searchProviderValues.indexOf(searchProviderId)]
addText(searchProviderDesc)
setOnClickListener {
screenManager.push(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_search_delete_recent))
setOnClickListener {
lifecycleScope.launch {
db.recentAutocompletePlaceDao().deleteAll()
CarToast.makeText(
carContext,
R.string.deleted_recent_search_results,
CarToast.LENGTH_SHORT
).show()
}
}
}.build())
}.build())
}.build()
}
}
class ChooseDataSourceScreen(
ctx: CarContext,
val type: Type,
val initialChoice: Boolean = false,
@StringRes val extraDesc: Int? = null
) : Screen(ctx) {
enum class Type {
CHARGER_DATA_SOURCE, SEARCH_PROVIDER
}
val prefs = PreferenceDataSource(carContext)
val title = when (type) {
Type.CHARGER_DATA_SOURCE -> R.string.pref_data_source
Type.SEARCH_PROVIDER -> R.string.pref_search_provider
}
val names = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_names)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_names)
}
val values = when (type) {
Type.CHARGER_DATA_SOURCE -> carContext.resources.getStringArray(R.array.pref_data_source_values)
Type.SEARCH_PROVIDER -> carContext.resources.getStringArray(R.array.pref_search_provider_values)
}
val currentValue: String = when (type) {
Type.CHARGER_DATA_SOURCE -> prefs.dataSource
Type.SEARCH_PROVIDER -> prefs.searchProvider
}
val descriptions = when (type) {
Type.CHARGER_DATA_SOURCE -> listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc)
)
Type.SEARCH_PROVIDER -> null
}
val callback: (String) -> Unit = when (type) {
Type.CHARGER_DATA_SOURCE -> { it ->
prefs.dataSourceSet = true
prefs.dataSource = it
}
Type.SEARCH_PROVIDER -> { it ->
prefs.searchProvider = it
}
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(title))
setHeaderAction(if (initialChoice) Action.APP_ICON else Action.BACK)
val list = ItemList.Builder().apply {
for (i in names.indices) {
addItem(Row.Builder().apply {
setTitle(names[i])
descriptions?.let { addText(it[i]) }
if (initialChoice) {
setBrowsable(true)
setOnClickListener {
itemSelected(i)
}
}
}.build())
}
if (!initialChoice) {
setOnSelectedListener {
itemSelected(it)
}
setSelectedIndex(values.indexOf(currentValue))
}
}.build()
if (extraDesc != null) {
addSectionedList(SectionedItemList.create(list, carContext.getString(extraDesc)))
} else {
setSingleList(list)
}
}.build()
}
private fun itemSelected(i: Int) {
callback(values[i])
screenManager.pop()
}
}
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectTariffsScreen(carContext))
}
addText(
if (prefs.chargepriceMyTariffsAll) {
carContext.getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
carContext.resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.resources.getQuantityString(
R.plurals.pref_my_tariffs_summary,
n
)
}
)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setBrowsable(true)
val range = prefs.chargepriceBatteryRangeAndroidAuto
addText(
carContext.getString(
R.string.chargeprice_battery_range,
range[0],
range[1]
)
)
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
val names =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values =
carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
val index = values.indexOf(prefs.chargepriceCurrency)
addText(if (index >= 0) names[index] else "")
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
}.build())
}
}.build())
}.build()
}
}
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = false
override fun isSelected(it: ChargepriceCar): Boolean {
return prefs.chargepriceMyVehicles.contains(it.id)
}
override fun toggleSelected(item: ChargepriceCar) {
if (isSelected(item)) {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.minus(item.id)
} else {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.plus(item.id)
}
}
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}
}
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: ChargepriceTariff): Boolean {
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
?: false)
}
override fun toggleSelected(item: ChargepriceTariff) {
val tariffs = prefs.chargepriceMyTariffs ?: if (prefs.chargepriceMyTariffsAll) {
fullList!!.map { it.id }.toSet()
} else {
emptySet()
}
if (isSelected(item)) {
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
prefs.chargepriceMyTariffsAll = false
} else {
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
prefs.chargepriceMyTariffsAll = true
}
}
}
override fun selectAll() {
prefs.chargepriceMyTariffsAll = true
super.selectAll()
}
override fun selectNone() {
prefs.chargepriceMyTariffsAll = false
prefs.chargepriceMyTariffs = emptySet()
super.selectNone()
}
override fun getLabel(it: ChargepriceTariff): String {
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}
override suspend fun loadData(): List<ChargepriceTariff> {
return api.getTariffs()
}
}
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
private val prefs = PreferenceDataSource(carContext)
override val isMultiSelect = false
override val shouldShowSelectAll = false
override fun isSelected(it: Pair<String, String>): Boolean =
prefs.chargepriceCurrency == it.second
override fun toggleSelected(item: Pair<String, String>) {
prefs.chargepriceCurrency = item.second
}
override fun getLabel(it: Pair<String, String>): String = it.first
override suspend fun loadData(): List<Pair<String, String>> {
val names = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_names)
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currency_values)
return names.zip(values)
}
}
class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID)
} else 6
override fun onGetTemplate(): Template {
return GridTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setHeaderAction(Action.BACK)
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_from))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[0]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = min(this[1] - 5, this[0] + 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_to))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[1]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = min(100f, this[1] + 5)
}
invalidate()
}
}.build())
val nSpacers = when {
maxItems % 3 == 0 -> 1
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
maxItems % 4 == 0 -> 2
else -> 0
}
for (i in 0..nSpacers) {
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(emptyCarIcon)
}.build())
}
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = max(0f, this[0] - 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = max(this[0] + 5, this[1] - 5)
}
invalidate()
}
}.build())
}.build()
)
}.build()
}
}

View File

@@ -0,0 +1,63 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import net.vonforst.evmap.R
class TextPromptScreen(
ctx: CarContext,
@StringRes val title: Int,
@StringRes val prompt: Int,
val initialValue: String? = null,
val cancelable: Boolean = true
) : Screen(ctx),
InputCallback {
private var inputText = ""
override fun onGetTemplate(): Template {
val signInMethod = InputSignInMethod.Builder(this).apply {
initialValue?.let {
setDefaultValue(it)
inputText = initialValue
}
setShowKeyboardByDefault(true)
}.build()
return SignInTemplate.Builder(signInMethod).apply {
setHeaderAction(Action.BACK)
setInstructions(carContext.getString(prompt))
setTitle(carContext.getString(title))
if (cancelable) {
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener(ParkedOnlyOnClickListener.create {
screenManager.pop()
})
.build()
)
}
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.ok))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
onInputSubmitted(inputText)
})
.build()
)
}.build()
}
override fun onInputTextChanged(text: String) {
inputText = text
}
override fun onInputSubmitted(text: String) {
setResult(text)
screenManager.pop()
}
}

View File

@@ -3,13 +3,13 @@ package net.vonforst.evmap.auto
import android.content.Context
import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.model.*
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
import kotlin.math.roundToInt
@@ -35,6 +35,14 @@ val CarContext.constraintManager
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
val emptyCarIcon: CarIcon by lazy {
Bitmap.createBitmap(
1,
1,
Bitmap.Config.ARGB_8888
).asCarIcon()
}
private const val kmPerMile = 1.609344
private const val ftPerMile = 5280
private const val ydPerMile = 1760
@@ -128,6 +136,40 @@ private fun roundToMultipleOf(num: Double, step: Double): Double {
return (num / step).roundToInt() * step
}
/**
* Paginates data based on specific limits for each page.
* If the data fits on a single page, this page can have a maximum size nSingle. Otherwise, the
* first page has maximum nFirst items, the last page nLast items, and all intermediate pages nOther
* items.
*/
fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): List<List<T>> {
if (nOther > nLast) {
throw IllegalArgumentException("nLast has to be larger than or equal to nOther")
}
return if (size <= nSingle) {
listOf(this)
} else {
val result = mutableListOf<List<T>>()
var i = 0
var page = 0
while (true) {
val remaining = size - i
if (page == 0) {
result.add(subList(i, i + nFirst))
i += nFirst
} else if (remaining <= nLast) {
result.add(subList(i, size))
break
} else {
result.add(subList(i, i + nOther))
i += nOther
}
page++
}
result
}
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
@@ -146,4 +188,17 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
}
}
return true
}
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
/*
Dummy screen to get around template refresh limitations.
It immediately pops back to the previous screen.
*/
override fun onGetTemplate(): Template {
screenManager.pop()
return MessageTemplate.Builder(carContext.getString(R.string.loading)).setLoading(true)
.build()
}
}

View File

@@ -6,32 +6,46 @@ 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.hardware.info.*
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 androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
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
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
private var model: Model? = null
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var heading: Compass? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private var compass =
CompassNeedle((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"
)
private val permissions = if (BuildConfig.FLAVOR_automotive == "automotive") {
listOf(
"android.car.permission.CAR_INFO",
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
"android.car.permission.READ_CAR_DISPLAY_UNITS",
"android.car.permission.CAR_SPEED"
)
} else {
listOf(
"com.google.android.gms.permission.CAR_FUEL",
"com.google.android.gms.permission.CAR_SPEED"
)
}
init {
lifecycle.addObserver(this)
@@ -44,7 +58,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
PermissionScreen(
carContext,
R.string.auto_vehicle_data_permission_needed,
permissions
permissions,
finishApp = false
)
) {
setupListeners()
@@ -55,6 +70,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
val energyLevel = energyLevel
val model = model
val speed = speed
val heading = heading
return GridTemplate.Builder().apply {
setTitle(
@@ -171,6 +187,25 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
}
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_heading))
if (heading == null) {
setLoading(true)
} else {
val heading = heading.orientations.value
if (heading != null) {
setText(
"${heading[0].roundToInt()}°"
)
} else {
setText(carContext.getString(R.string.auto_no_data))
}
setImage(
compass.draw(heading?.get(0)).asCarIcon()
)
}
}.build())
}.build()
)
}
@@ -187,27 +222,44 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun onCompassUpdated(compass: Compass) {
this.heading = compass
invalidate()
}
override fun onResume(owner: LifecycleOwner) {
setupListeners()
}
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)
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
carInfo.addSpeedListener(exec, ::onSpeedUpdated)
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
hardwareMan.carInfo.fetchModel(exec) {
carInfo.fetchModel(exec) {
this.model = it
invalidate()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
override fun onPause(owner: LifecycleOwner) {
removeListeners()
}
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carInfo.removeSpeedListener(::onSpeedUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
private fun permissionsGranted(): Boolean =

View File

@@ -1,121 +0,0 @@
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.*
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
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))
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()
)
.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()
)
}
}.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,33 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.PorterDuff
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
class CompassNeedle(val size: Int, ctx: Context) {
val image = ContextCompat.getDrawable(ctx, R.drawable.ic_navigation)!!
init {
image.setTint(Color.WHITE)
image.setBounds(0, 0, size, size)
}
fun draw(angle: Float?): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
if (angle != null) {
canvas.save()
canvas.rotate(-angle, size / 2f, size / 2f)
image.draw(canvas)
canvas.restore()
}
return bitmap
}
}

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="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C6.08,3 3.28,5.64 3.03,9h2.02C5.3,6.75 7.18,5 9.5,5C11.99,5 14,7.01 14,9.5S11.99,14 9.5,14c-0.17,0 -0.33,-0.03 -0.5,-0.05v2.02C9.17,15.99 9.33,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57L14,14.71v0.79l5,4.99L20.49,19L15.5,14z" />
<path
android:fillColor="@android:color/white"
android:pathData="M6.47,10.82l-2.47,2.47l-2.47,-2.47l-0.71,0.71l2.47,2.47l-2.47,2.47l0.71,0.71l2.47,-2.47l2.47,2.47l0.71,-0.71l-2.47,-2.47l2.47,-2.47z" />
</vector>

View File

@@ -1,13 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<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>
@@ -19,6 +11,7 @@
<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_chargers_near_location">Nahe %s</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>
@@ -27,11 +20,20 @@
<string name="auto_no_data">Nicht verfügbar</string>
<string name="auto_range">Reichweite</string>
<string name="auto_speed">Geschwindigkeit</string>
<string name="auto_heading">Fahrtrichtung</string>
<string name="auto_settings">Einstellungen</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargers_ahead">Nur Ladestationen in Fahrtrichtung</string>
<string name="settings_android_auto_chargeprice_range">Ladebereich für Preisvergleich</string>
<string name="data_sources_hint">In den Einstellungen kannst du auch zwischen Google Maps und OpenStreetMap (Mapbox) für die Kartendaten wechseln.</string>
<string name="selecting_all">alle Einträge ausgewählt</string>
<string name="selecting_none">alle Einträge abgewählt</string>
<string name="loading">Lade…</string>
<string name="auto_multipage_goto">Seite %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Trouvez-vous EVMap utile \? Soutenez son développement en envoyant un don au développeur.
\n
\nGoogle prend 15% sur chaque don.</string>
<string name="auto_location_service">EVMap fonctionne sur Android Auto et utilise votre position.</string>
<string name="open_in_app">Ouvrir dans l\'application</string>
<string name="opened_on_phone">Ouvert sur le téléphone</string>
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
<string name="grant_on_phone">Grant au téléphone</string>
<string name="auto_prices">Prix</string>
<string name="auto_vehicle_data">Données sur le véhicule</string>
<string name="auto_range">Autonomie</string>
<string name="auto_speed">Vitesse</string>
<string name="welcome_android_auto">Prise en charge dAndroid Auto</string>
<string name="sounds_cool">ça a l\'air cool</string>
<string name="auto_chargeprice_vehicle_unknown">Aucun des véhicules sélectionnés dans l\'application ne correspond à ce véhicule (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Plusieurs véhicules sélectionnés dans l\'application correspondent à ce véhicule (%1$s %2$s).</string>
<string name="selecting_all">tous les éléments sélectionnés</string>
<string name="data_sources_hint">Dans les paramètres, vous pouvez également choisir entre Google Maps et OpenStreetMap (Mapbox) pour les données cartographiques.</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap n\'a pas pu déterminer le modèle de votre véhicule.</string>
<string name="auto_no_chargers_found">Aucun chargeur à proximité n\'a été trouvé</string>
<string name="auto_no_favorites_found">Pas de favoris trouvés</string>
<string name="auto_charging_level">Niveau de charge</string>
<string name="auto_chargers_closeby">Chargeurs à proximité</string>
<string name="auto_chargers_near_location">Près de %s</string>
<string name="auto_fault_report_date">⚠️ Rapport d\'anomalie (%s)</string>
<string name="auto_no_data">Indisponible</string>
<string name="auto_settings">Paramètres</string>
<string name="selecting_none">désélectionner tous les éléments</string>
<string name="auto_vehicle_data_permission_needed">Pour cette fonction, EVMap doit avoir accès aux données de votre véhicule.</string>
<string name="auto_heading">Direction</string>
<string name="auto_favorites">Favoris</string>
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
<string name="loading">Chargement…</string>
</resources>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="donations_info" formatted="false">Synes du EVMap er nyttig\? Støtt utviklingen ved å sende penger til utvikleren.
\n
\nGoogle tar 15% av alle donasjoner.</string>
<string name="auto_favorites">Favoritter</string>
<string name="auto_charging_level">Ladingsnivå</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap kunne ikke fastsette kjøretøymodellen.</string>
<string name="selecting_none">fravalgte alle elementer</string>
<string name="grant_on_phone">Innvilg på mobilenheten</string>
<string name="auto_chargers_closeby">Ladere i nærheten</string>
<string name="auto_prices">Pris</string>
<string name="auto_no_chargers_found">Ingen ladere i nærheten</string>
<string name="auto_no_favorites_found">Fant ikke noen favoritter</string>
<string name="open_in_app">Åpne i programmet</string>
<string name="auto_location_service">EVMap kjører på Android Auto og bruker posisjonen din.</string>
<string name="auto_heading">Fartsretning</string>
<string name="opened_on_phone">Åpnet på mobilenheten</string>
<string name="auto_location_permission_needed">Innvilg posisjonstilgang for å bruke EVMap på Android Auto.</string>
<string name="auto_chargers_near_location">Nær %s</string>
<string name="auto_fault_report_date">⚠️ Feilrapport (%s)</string>
<string name="auto_vehicle_data">Kjøretøydata</string>
<string name="auto_no_data">Utilgjengelig</string>
<string name="auto_speed">Hastighet</string>
<string name="auto_settings">Innstillinger</string>
<string name="auto_chargeprice_vehicle_unknown">Ingen av kjøretøyene valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="welcome_android_auto">Android Auto-støtte</string>
<string name="auto_chargeprice_vehicle_ambiguous">Flere kjøretøy valgt i programmet samsvarer med dette kjøretøyet (%1$s %2$s).</string>
<string name="auto_vehicle_data_permission_needed">EvMap trenger tilgang til kjøretøydata for å bruke denne funksjonen.</string>
<string name="auto_no_refresh_possible">Videre oppdateringer er ikke mulig. Gå tilbake og start på ny.</string>
<string name="auto_range">Rekkevidde</string>
<string name="welcome_android_auto_detail">Du kan også bruke EVMap inne i Android Auto på bilder som støtter dette ved å velge det i Android Auto-menyen.</string>
<string name="settings_android_auto_chargeprice_range">Prissammenligning for laderekkevidde fordelt på pris</string>
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
<string name="selecting_all">valgte alle elementene</string>
<string name="sounds_cool">den er grei</string>
<string name="auto_chargers_ahead">Kun ladere i kjøreretningen</string>
<string name="loading">Laster inn …</string>
</resources>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>@string/pref_provider_google_maps</item>
<item>@string/pref_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_map_provider_values" translatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>@string/pref_provider_google_maps</item>
<item>@string/pref_provider_osm_mapbox</item>
</string-array>
<string-array name="pref_search_provider_values" translatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
</resources>

View File

@@ -3,5 +3,5 @@
<color name="gauge_active">#00e676</color>
<color name="gauge_middle">#087f23</color>
<color name="gauge_inactive">#9e9e9e</color>
<color name="charger_100kw_dark">#fdd835</color>
<color name="charger_100kw_dark">#FBC02D</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
</resources>

View File

@@ -1,23 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_map_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_map_provider_values" tranlatable="false">
<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>
@@ -29,6 +11,7 @@
<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_chargers_near_location">Near %s</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>
@@ -37,11 +20,20 @@
<string name="auto_no_data">Unavailable</string>
<string name="auto_range">Range</string>
<string name="auto_speed">Speed</string>
<string name="auto_heading">Heading</string>
<string name="auto_settings">Settings</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%1$s %2$s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Multiple vehicles selected in the app match this vehicle (%1$s %2$s).</string>
<string name="auto_chargers_ahead">Only chargers along driving direction</string>
<string name="settings_android_auto_chargeprice_range">Charging range for price comparison</string>
<string name="data_sources_hint">In the settings you can also switch between Google Maps and OpenStreetMap (Mapbox) for the map data.</string>
<string name="selecting_all">selected all items</string>
<string name="selecting_none">deselected all items</string>
<string name="loading">Loading…</string>
<string name="auto_multipage_goto">Page %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.car.permission.CAR_INFO" />
<uses-permission android:name="android.car.permission.CAR_ENERGY" />
<uses-permission android:name="android.car.permission.CAR_ENERGY_PORTS" />
<uses-permission android:name="android.car.permission.READ_CAR_DISPLAY_UNITS" />
<uses-permission android:name="android.car.permission.CAR_SPEED" />
<uses-feature
android:name="android.hardware.type.automotive"
android:required="true" />
<uses-feature
android:name="android.software.car.templates_host"
android:required="true" />
<uses-feature
android:name="android.hardware.wifi"
tools:replace="required"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.landscape"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application>
<meta-data
android:name="com.android.automotive"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="com.google.android.gms.car.application"
tools:node="remove" />
<activity
android:name=".MapsActivity"
tools:node="remove" />
<activity
android:exported="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:name="androidx.car.app.activity.CarAppActivity"
android:launchMode="singleTask"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="distractionOptimized"
android:value="true" />
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Zulassen</string>
<string name="auto_location_permission_needed">Um EVMap auf deinem Fahrzeug zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Autoriser</string>
<string name="auto_location_permission_needed">Pour exécuter EVMap sur Android Auto, vous devez autoriser l\'accès à votre emplacement.</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auto_location_permission_needed">Du må du innvilge posisjonstilgang for å kjøre EVMap i bilen din.</string>
<string name="grant_on_phone">Tillat</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="grant_on_phone">Allow</string>
<string name="auto_location_permission_needed">To run EVMap on your car, you need to grant access to your location.</string>
</resources>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.vonforst.evmap">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
@@ -15,6 +15,9 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
@@ -24,7 +27,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
<meta-data
android:name="com.mapbox.ACCESS_TOKEN"
@@ -252,6 +256,10 @@
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Ungarn/..*/..*/..*/"
android:scheme="https" />
<data
android:host="openchargemap.org"
android:pathPattern="/site/poi/details/..*"
android:scheme="https" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -262,16 +270,14 @@
android:resource="@xml/shortcuts" />
</activity>
<!-- Override services of the com.mapzen.android.lost library with exported:false
until https://github.com/lostzen/lost/pull/270 is merged -->
<service
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
</intent-filter>
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap
import android.app.Application
import com.facebook.stetho.Stetho
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.limiter
@@ -13,7 +14,16 @@ import org.acra.ktx.initAcra
class EvMapApplication : Application() {
override fun onCreate() {
super.onCreate()
updateNightMode(PreferenceDataSource(this))
val prefs = PreferenceDataSource(this)
updateNightMode(prefs)
// Convert to new AppCompat storage for app language
val lang = prefs.language
if (lang != null && lang !in listOf("", "default")) {
updateAppLocale(lang)
prefs.language = null
}
Stetho.initializeWithDefaults(this);
init(applicationContext)

View File

@@ -2,15 +2,16 @@ package net.vonforst.evmap
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
@@ -32,11 +33,9 @@ import net.vonforst.evmap.fragment.MapFragmentArgs
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.navigation.NavHostFragment
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
import net.vonforst.evmap.utils.getLocationFromIntent
const val REQUEST_LOCATION_PERMISSION = 1
const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
@@ -54,14 +53,6 @@ class MapsActivity : AppCompatActivity(),
var fragmentCallback: FragmentCallback? = null
private lateinit var prefs: PreferenceDataSource
override fun attachBaseContext(newBase: Context) {
return super.attachBaseContext(
LocaleContextWrapper.wrap(
newBase, PreferenceDataSource(newBase).language
)
);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
@@ -84,9 +75,9 @@ class MapsActivity : AppCompatActivity(),
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
val header = navView.getHeaderView(0)
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
val header = navView.getHeaderView(0)
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
}
@@ -98,7 +89,7 @@ class MapsActivity : AppCompatActivity(),
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 {
splashScreen.setKeepOnScreenCondition(object : SplashScreen.KeepOnScreenCondition {
var startTime: Long? = null
override fun shouldKeepOnScreen(): Boolean {
@@ -142,6 +133,37 @@ class MapsActivity : AppCompatActivity(),
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
if (prefs.dataSource != "goingelectric") {
prefs.dataSource = "goingelectric"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_goingelectric)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
if (prefs.dataSource != "openchargemap") {
prefs.dataSource = "openchargemap"
Toast.makeText(
this,
getString(
R.string.data_source_switched_to,
getString(R.string.data_source_openchargemap)
),
Toast.LENGTH_LONG
).show()
}
deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
@@ -163,6 +185,7 @@ class MapsActivity : AppCompatActivity(),
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_FAVORITES)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.favs)
.createPendingIntent()
}
@@ -206,6 +229,7 @@ class MapsActivity : AppCompatActivity(),
}
fun openUrl(url: String) {
val pkg = CustomTabsClient.getPackageName(this, null)
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -213,6 +237,11 @@ class MapsActivity : AppCompatActivity(),
.build()
)
.build()
pkg?.let {
// prefer to open URL in custom tab, even if native app
// available (such as EVMap itself)
intent.intent.setPackage(pkg)
}
try {
intent.launchUrl(this, Uri.parse(url))
} catch (e: ActivityNotFoundException) {

View File

@@ -6,11 +6,6 @@ import android.graphics.Typeface
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
import androidx.lifecycle.LiveData
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? {
@@ -85,25 +80,6 @@ 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 ->
val observer = object : Observer<T> {
override fun onChanged(value: T) {
removeObserver(this)
continuation.resume(value, null)
}
}
observeForever(observer)
continuation.invokeOnCancellation {
removeObserver(observer)
}
}
}
}
fun Context.isDarkMode() =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES

View File

@@ -161,6 +161,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
val binding = holder.binding as ItemConnectorButtonBinding
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
val root = binding.root as CheckableConstraintLayout
root.setOnCheckedChangeListener { _, _ -> }
root.isChecked = checkedItem == position
root.setOnClickListener {
root.isChecked = true
@@ -171,7 +172,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
root.post {
notifyDataSetChanged()
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
getCheckedItem()?.let { onCheckedItemChangedListener?.invoke(it) }
}
}
}

View File

@@ -44,13 +44,13 @@ fun buildDetails(
if (loc == null) return emptyList()
return listOfNotNull(
DetailsAdapter.Detail(
if (loc.address != null) DetailsAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription,
clickable = true
),
) else null,
if (loc.operator != null) DetailsAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,
@@ -91,7 +91,7 @@ fun buildDetails(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
loc.cost.getDetailText()
)
else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty() || loc.barrierFree == true)

View File

@@ -14,7 +14,7 @@ class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> U
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
override fun getItemId(position: Int): Long = getItem(position).fav.favorite.favoriteId
@SuppressLint("ClickableViewAccessibility")
override fun bind(

View File

@@ -62,6 +62,8 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
)
}
}
is BooleanFilterValue -> {
}
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.adapter
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
class SingleViewAdapter(val view: View) : Adapter<SingleViewAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(view)
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view)
override fun getItemCount() = 1
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
}
}

View File

@@ -34,7 +34,8 @@ interface ChargepointApi<out T : ReferenceData> {
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
fun getName(): String
val name: String
val id: String
}
interface StringProvider {

View File

@@ -21,6 +21,14 @@ import java.util.concurrent.TimeUnit
interface AvailabilityDetector {
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
/**
* Get a rough estimate whether this charger is supported by this provider.
*
* This might be done by checking supported countries, or even by matching the operator
* for operator-specific availability detectors.
*/
fun isChargerSupported(charger: ChargeLocation): Boolean
}
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
@@ -86,7 +94,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
// find corresponding powers in GE data
val gePowers =
chargepoints.filter { equivalentPlugTypes(it.type).any { it == type } }
.map { it.power }.distinct().sorted()
.mapNotNull { it.power }.distinct().sorted()
// if the distinct number of powers is the same, try to match.
if (powers.size == gePowers.size) {
@@ -124,14 +132,15 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
(connectors == null || connectors.map {
equivalentPlugTypes(it)
}.any { equivalent -> it.type in equivalent })
&& (minPower == null || it.power > minPower)
&& (minPower == null || (it.power != null && it.power >= minPower))
}
return this.copy(status = statusFiltered)
}
@@ -157,21 +166,16 @@ private val okhttp = OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
EnBwAvailabilityDetector(okhttp),
NewMotionAvailabilityDetector(okhttp)
/*ChargecloudAvailabilityDetector(
okhttp,
"606a0da0dfdd338ee4134605653d4fd8"
), // Maingau
ChargecloudAvailabilityDetector(
okhttp,
"6336fe713f2eb7fa04b97ff6651b76f8"
) // SW Kiel*/
)
suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationStatus> {
var value: Resource<ChargeLocationStatus>? = null
withContext(Dispatchers.IO) {
for (ad in availabilityDetectors) {
if (!ad.isChargerSupported(charger)) continue
try {
value = Resource.success(ad.getAvailability(charger))
break
@@ -188,4 +192,4 @@ suspend fun getAvailability(charger: ChargeLocation): Resource<ChargeLocationSta
}
}
return value ?: Resource.error(null, null)
}
}

View File

@@ -1,83 +1,110 @@
package net.vonforst.evmap.api.availability
import net.vonforst.evmap.api.iterator
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
class ChargecloudAvailabilityDetector(
client: OkHttpClient,
private val operatorId: String
interface ChargecloudApi {
@GET("locations")
suspend fun getData(
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius") radius: Int,
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 10
): ChargecloudResponse
@JsonClass(generateAdapter = true)
data class ChargecloudResponse(
val data: List<ChargecloudLocation>
)
@JsonClass(generateAdapter = true)
data class ChargecloudLocation(
val coordinates: ChargecloudCoordinates,
val evses: List<ChargecloudEvse>,
@Json(name = "distance_in_m") val distanceInM: String
)
@JsonClass(generateAdapter = true)
data class ChargecloudCoordinates(val latitude: Double, val longitude: Double)
@JsonClass(generateAdapter = true)
data class ChargecloudEvse(
val id: String,
val status: String,
val connectors: List<ChargecloudConnector>
)
@JsonClass(generateAdapter = true)
data class ChargecloudConnector(
val id: Long,
val standard: String,
@Json(name = "max_power") val maxPower: Double,
val status: String
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): ChargecloudApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
return retrofit.create(ChargecloudApi::class.java)
}
}
}
abstract class ChargecloudAvailabilityDetector(
client: OkHttpClient
) : BaseAvailabilityDetector(client) {
protected abstract val operatorId: String
private val api: ChargecloudApi by lazy {
val baseUrl = "https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/"
ChargecloudApi.create(client, baseUrl)
}
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val url =
"https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/locations?latitude=${location.coordinates.lat}&longitude=${location.coordinates.lng}&radius=$radius&offset=0&limit=10"
val json = JSONObject(httpGet(url))
val data = api.getData(location.coordinates.lat, location.coordinates.lng, radius)
val statusMessage = json.getString("status_message")
if (statusMessage != "Success") throw IOException(statusMessage)
val nearest = data.data.minByOrNull { it.distanceInM.toDouble() }
?: throw AvailabilityDetectorException("no candidates found.")
val data = json.getJSONArray("data")
if (data.length() > 1) throw AvailabilityDetectorException(
"found multiple candidates."
)
if (data.length() == 0) throw AvailabilityDetectorException(
"no candidates found."
)
val chargecloudConnectors = mutableMapOf<Long, Pair<Double, String>>()
val chargecloudStatus = mutableMapOf<Long, ChargepointStatus>()
val evses = data.getJSONObject(0).getJSONArray("evses")
val chargepointStatus = mutableMapOf<Chargepoint, List<ChargepointStatus>>()
evses.iterator<JSONObject>().forEach { evse ->
evse.getJSONArray("connectors").iterator<JSONObject>().forEach connector@{ connector ->
val type = getType(connector.getString("standard"))
val power = connector.getDouble("max_power")
val status = ChargepointStatus.valueOf(connector.getString("status"))
var chargepoint = getCorrespondingChargepoint(chargepointStatus.keys, type, power)
val statusList: List<ChargepointStatus>
if (chargepoint == null) {
// find corresponding chargepoint from goingelectric to get correct power
val geChargepoint =
getCorrespondingChargepoint(location.chargepointsMerged, type, power)
?: throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
chargepoint = Chargepoint(
type,
geChargepoint.power,
1
)
statusList = listOf(status)
} else {
val previousStatus = chargepointStatus[chargepoint]!!
statusList = previousStatus + listOf(status)
chargepointStatus.remove(chargepoint)
chargepoint =
Chargepoint(
chargepoint.type,
chargepoint.power,
chargepoint.count + 1
)
}
chargepointStatus[chargepoint] = statusList
nearest.evses.flatMap { it.connectors }.forEach {
val id = it.id
val power = it.maxPower
val type = getType(it.standard)
val status = when (it.status) {
"OUTOFORDER" -> ChargepointStatus.FAULTED
"AVAILABLE" -> ChargepointStatus.AVAILABLE
"CHARGING" -> ChargepointStatus.CHARGING
"UNKNOWN" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
chargecloudConnectors.put(id, power to type)
chargecloudStatus.put(id, status)
}
if (chargepointStatus.keys == location.chargepointsMerged.toSet()) {
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"
)
} else {
throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
val match = matchChargepoints(chargecloudConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { chargecloudStatus[it]!! }
}
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"
)
}
private fun getType(string: String): String {
@@ -86,7 +113,24 @@ class ChargecloudAvailabilityDetector(
"DOMESTIC_F" -> Chargepoint.SCHUKO
"IEC_62196_T2_COMBO" -> Chargepoint.CCS_TYPE_2
"CHADEMO" -> Chargepoint.CHADEMO
else -> throw IllegalArgumentException("unrecognized type $string")
else -> "unknown"
}
}
}
}
class RheinenergieAvailabilityDetector(client: OkHttpClient) :
ChargecloudAvailabilityDetector(client) {
override val operatorId = "c4ce9bb82a86766833df8a4818fa1b5c"
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val network = charger.chargepriceData?.network ?: charger.network ?: return false
return when (charger.dataSource) {
"goingelectric" -> network == "RheinEnergie"
"openchargemap" -> network == "72"
else -> false
}
}
}
// "606a0da0dfdd338ee4134605653d4fd8" Maingau
// "6336fe713f2eb7fa04b97ff6651b76f8" SW Kiel*/

View File

@@ -0,0 +1,233 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 40 // max distance between reported positions in meters
interface EnBwApi {
@GET("chargestations?grouping=false")
suspend fun getMarkers(
@Query("fromLon") fromLon: Double,
@Query("toLon") toLon: Double,
@Query("fromLat") fromLat: Double,
@Query("toLat") toLat: Double,
): List<EnBwLocation>
@GET("chargestations/{id}")
suspend fun getLocation(@Path("id") id: Long): EnBwLocationDetail
@JsonClass(generateAdapter = true)
data class EnBwLocation(
val lat: Double,
val lon: Double,
val stationId: Long?,
val grouped: Boolean,
val availableChargePoints: Int,
val numberOfChargePoints: Int,
val operator: String,
val viewPort: EnBwViewport
)
@JsonClass(generateAdapter = true)
data class EnBwLocationDetail(
val lat: Double,
val lon: Double,
val stationId: Long,
val availableChargePoints: Int,
val numberOfChargePoints: Int,
val operator: String,
val chargePoints: List<EnBwChargePoint>
)
@JsonClass(generateAdapter = true)
data class EnBwChargePoint(
val evseId: String?,
val status: String,
val connectors: List<EnBwConnector>
)
@JsonClass(generateAdapter = true)
data class EnBwConnector(
val plugTypeName: String,
val maxPowerInKw: Double?,
)
@JsonClass(generateAdapter = true)
data class EnBwViewport(
val lowerLeftLat: Double,
val lowerLeftLon: Double,
val upperRightLat: Double,
val upperRightLon: Double
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
val clientWithInterceptor = client.newBuilder()
.addInterceptor { chain ->
// add API key to every request
val request = chain.request().newBuilder()
.header("Ocp-Apim-Subscription-Key", "d4954e8b2e444fc89a89a463788c0a72")
.header("Origin", "https://www.enbw.com")
.header("Referer", "https://www.enbw.com/")
.header("Accept", "application/json")
.build()
chain.proceed(request)
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
.addConverterFactory(MoshiConverterFactory.create())
.client(clientWithInterceptor)
.build()
return retrofit.create(EnBwApi::class.java)
}
}
}
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = EnBwApi.create(client, baseUrl)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val lat = location.coordinates.lat
val lng = location.coordinates.lng
// find nearest station to this position
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
markers = markers.flatMap {
if (it.grouped) {
api.getMarkers(
it.viewPort.lowerLeftLon,
it.viewPort.upperRightLon,
it.viewPort.lowerLeftLat,
it.viewPort.upperRightLat
)
} else {
listOf(it)
}
}
val nearest = markers.minByOrNull { marker ->
distanceBetween(marker.lat, marker.lon, lat, lng)
} ?: throw AvailabilityDetectorException("no candidates found.")
if (distanceBetween(
nearest.lat,
nearest.lon,
lat,
lng
) > radius
) {
throw AvailabilityDetectorException("no candidates found")
}
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.lat,
marker.lon,
nearest.lat,
nearest.lon
) < maxDistance
}
var details = markers.filter {
// only include stations from same operator
it.operator == nearest.operator && it.stationId != null
}.map {
// load details
api.getLocation(it.stationId!!)
}
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
cp.connectors.map { connector ->
Triple(connector, cp.status, cp.evseId)
}
}
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
val enbwEvseId = mutableMapOf<Long, String>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId) ->
val id = index.toLong()
val power = connector.maxPowerInKw ?: 0.0
val type = when (connector.plugTypeName) {
"Typ 3A" -> Chargepoint.TYPE_3
"Typ 2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ 1" -> Chargepoint.TYPE_1
"Steckdose(D)" -> Chargepoint.SCHUKO
"CCS (Typ 1)" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
"CCS (Typ 2)" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
"CHAdeMO" -> Chargepoint.CHADEMO
else -> "unknown"
}
val status = when (statusStr) {
"UNAVAILABLE" -> ChargepointStatus.FAULTED
"OUT_OF_SERVICE" -> ChargepointStatus.FAULTED
"AVAILABLE" -> ChargepointStatus.AVAILABLE
"OCCUPIED" -> ChargepointStatus.CHARGING
"UNSPECIFIED" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
enbwConnectors[id] = power to type
enbwStatus[id] = status
evseId?.let { enbwEvseId[id] = it }
}
val match = matchChargepoints(enbwConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { enbwStatus[it]!! }
}
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
entry.value.map { enbwEvseId[it]!! }
} else null
return ChargeLocationStatus(
chargepointStatus,
"EnBW",
evseIds
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
val country = charger.chargepriceData?.country
?: charger.address?.country ?: return false
return when (charger.dataSource) {
// list of countries as of 2021/06/30, according to
// https://www.electrive.net/2021/06/30/enbw-expandiert-mit-ladenetz-in-drei-weitere-laender/
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Liechtenstein",
"Italien",
)
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"LI",
"IT"
)
else -> false
}
}
}

View File

@@ -11,8 +11,8 @@ import retrofit2.http.GET
import retrofit2.http.Path
import java.util.*
private const val coordRange = 0.1 // range of latitude and longitude for loading the map
private const val maxDistance = 15 // max distance between reported positions in meters
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 40 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
@@ -130,13 +130,14 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
}
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
evse.connectors.map { connector ->
connector to evse.status
Triple(connector, evse.status, evse.evseId)
}
}
val nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
connectorStatus.forEach { (connector, statusStr) ->
val nmEvseId = mutableMapOf<Long, String>()
connectorStatus.forEach { (connector, statusStr, evseId) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
@@ -161,16 +162,26 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
}
nmConnectors.put(id, power to type)
nmStatus.put(id, status)
evseId?.let { nmEvseId[id] = it }
}
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
val chargepointStatus = match.mapValues { entry ->
entry.value.map { nmStatus[it]!! }
}
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
entry.value.map { nmEvseId[it]!! }
} else null
return ChargeLocationStatus(
chargepointStatus,
"NewMotion"
"NewMotion",
evseIds
)
}
override fun isChargerSupported(charger: ChargeLocation): Boolean {
// NewMotion is our fallback
return true
}
}

View File

@@ -3,14 +3,16 @@ package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import moe.banana.jsonapi2.ArrayDocument
import moe.banana.jsonapi2.JsonApiConverterFactory
import moe.banana.jsonapi2.ResourceAdapterFactory
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
@@ -20,34 +22,45 @@ import java.util.*
interface ChargepriceApi {
@POST("charge_prices")
suspend fun getChargePrices(
@Body request: ChargepriceRequest,
@Body @jsonapi.retrofit.Document request: ChargepriceRequest,
@Header("Accept-Language") language: String
): ArrayDocument<ChargePrice>
): Document<List<ChargePrice>>
@GET("vehicles")
suspend fun getVehicles(): ArrayDocument<ChargepriceCar>
@jsonapi.retrofit.Document
suspend fun getVehicles(): List<ChargepriceCar>
@GET("tariffs")
suspend fun getTariffs(): ArrayDocument<ChargepriceTariff>
@jsonapi.retrofit.Document
suspend fun getTariffs(): List<ChargepriceTariff>
@POST("user_feedback")
suspend fun userFeedback(@Body @jsonapi.retrofit.Document feedback: ChargepriceUserFeedback)
companion object {
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 DATA_SOURCE_GOINGELECTRIC = "going_electric"
private val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
.add(ChargepriceRequest::class.java)
.add(ChargepriceTariff::class.java)
.add(ChargepriceBrand::class.java)
.add(ChargePrice::class.java)
.add(ChargepriceCar::class.java)
private val jsonApiAdapterFactory = JsonApiFactory.Builder()
.addType(ChargepriceRequest::class.java)
.addType(ChargepriceTariff::class.java)
.addType(ChargepriceBrand::class.java)
.addType(ChargePrice::class.java)
.addType(ChargepriceCar::class.java)
.build()
val moshi = Moshi.Builder()
.add(jsonApiAdapterFactory)
.add(KotlinJsonAdapterFactory())
.add(
PolymorphicJsonAdapterFactory.of(ChargepriceUserFeedback::class.java, "type")
.withSubtype(ChargepriceMissingPriceFeedback::class.java, "missing_price")
.withSubtype(ChargepriceWrongPriceFeedback::class.java, "wrong_price")
.withSubtype(ChargepriceMissingVehicleFeedback::class.java, "missing_vehicle")
)
.build()
fun create(
apikey: String,
baseurl: String = "https://api.chargeprice.app/v1/",
@@ -73,7 +86,8 @@ interface ChargepriceApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(JsonApiConverterFactory.create(moshi))
.addConverterFactory(DocumentConverterFactory.create())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(ChargepriceApi::class.java)
@@ -89,18 +103,56 @@ interface ChargepriceApi {
}
}
fun getPoiUrl(charger: ChargeLocation) =
"https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter(charger)}"
fun getDataAdapter(charger: ChargeLocation) = when (charger.dataSource) {
"goingelectric" -> DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> DATA_SOURCE_OPENCHARGEMAP
else -> throw IllegalArgumentException()
}
/**
* Checks if a charger is supported by Chargeprice.
*
* This function just applies some heuristics on the charger's data without making API
* calls. If it returns true, that is not a guarantee that Chargeprice will have information
* on this charger. But if it is false, it is pretty unlikely that Chargeprice will have
* useful data, so we do not show the price comparison button in this case.
*/
@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",
fun isChargerSupported(charger: ChargeLocation): Boolean {
val dataSourceSupported = charger.dataSource in listOf("goingelectric", "openchargemap")
val countrySupported =
charger.chargepriceData?.country?.let { isCountrySupported(it, charger.dataSource) }
?: false
val networkSupported = charger.chargepriceData?.network?.let {
if (charger.dataSource == "openchargemap") {
it !in listOf(
"1", // unknown operator
"44", // private residence/individual
"45" // business owner at location
)
} else {
true
}
} ?: false
val powerAvailable = charger.chargepoints.all { it.hasKnownPower() }
return dataSourceSupported && countrySupported && networkSupported && powerAvailable
}
private fun isCountrySupported(country: String, dataSource: String): Boolean =
when (dataSource) {
"goingelectric" -> country in listOf(
// list of countries according to Chargeprice.app, 2021/08/24
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Dänemark",
"Norwegen",
"Schweden",
"Slowenien",
@@ -110,9 +162,28 @@ interface ChargepriceApi {
"Italien",
"Spanien",
"Großbritannien",
"Irland"
"Irland",
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
"Finnland",
"Lettland",
"Litauen",
"Estland",
"Liechtenstein",
"Rumänien",
"Slowakei",
"Slowenien",
"Polen",
"Serbien",
"Bulgarien",
"Kosovo",
"Montenegro",
"Albanien",
"Griechenland",
"Portugal",
"Island"
)
"openchargemap" -> country in listOf(
// list of countries according to Chargeprice.app, 2021/08/24
"DE",
"AT",
"CH",
@@ -130,7 +201,25 @@ interface ChargepriceApi {
"IT",
"ES",
"GB",
"IE"
"IE",
// additional countries found 2022/09/17, https://github.com/johan12345/EVMap/issues/234
"FI",
"LV",
"LT",
"EE",
"LI",
"RO",
"SK",
"SI",
"PL",
"RS",
"BG",
"XK",
"ME",
"AL",
"GR",
"PT",
"IS"
)
else -> false
}

View File

@@ -1,12 +1,15 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.Patterns
import com.squareup.moshi.Json
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonApi
import moe.banana.jsonapi2.Resource
import com.squareup.moshi.JsonClass
import jsonapi.*
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
@@ -17,16 +20,21 @@ import kotlin.math.ceil
import kotlin.math.floor
@JsonApi(type = "charge_price_request")
class ChargepriceRequest : Resource() {
@field:Json(name = "data_adapter")
lateinit var dataAdapter: String
lateinit var station: ChargepriceStation
lateinit var options: ChargepriceOptions
var tariffs: HasMany<ChargepriceTariff>? = null
var vehicle: HasOne<ChargepriceCar>? = null
}
@Resource("charge_price_request")
@JsonClass(generateAdapter = true)
data class ChargepriceRequest(
@Json(name = "data_adapter")
val dataAdapter: String,
val station: ChargepriceStation,
val options: ChargepriceOptions,
@ToMany("tariffs")
val tariffs: List<ChargepriceTariff>? = null,
@ToOne("vehicle")
val vehicle: ChargepriceCar? = null,
@RelationshipsObject var relationships: Relationships? = null
)
@JsonClass(generateAdapter = true)
data class ChargepriceStation(
val longitude: Double,
val latitude: Double,
@@ -48,21 +56,21 @@ data class ChargepriceStation(
charger.coordinates.lat,
charger.chargepriceData.country,
charger.chargepriceData.network,
charger.chargepoints.zip(plugTypes).filter {
equivalentPlugTypes(it.first.type).any { it in compatibleConnectors }
}.map {
ChargepriceChargepoint(it.first.power, it.second)
}
charger.chargepoints.zip(plugTypes)
.filter { equivalentPlugTypes(it.first.type).any { it in compatibleConnectors } }
.map { ChargepriceChargepoint(it.first.power ?: 0.0, it.second) }
)
}
}
}
@JsonClass(generateAdapter = true)
data class ChargepriceChargepoint(
val power: Double,
val plug: String
)
@JsonClass(generateAdapter = true)
data class ChargepriceOptions(
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
val energy: Double? = null,
@@ -72,144 +80,114 @@ data class ChargepriceOptions(
val currency: String? = null,
@Json(name = "start_time") val startTime: Int? = null,
@Json(name = "allow_unbalanced_load") val allowUnbalancedLoad: Boolean? = null,
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null,
@Json(name = "show_price_unavailable") val showPriceUnavailable: Boolean? = null,
@Json(name = "show_all_brand_restricted_tariffs") val showAllBrandRestrictedTariffs: Boolean? = null
)
@JsonApi(type = "tariff")
class ChargepriceTariff() : Resource() {
lateinit var provider: String
lateinit var name: String
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
@field:Json(name = "supported_cuntries")
lateinit var supportedCountries: Set<String>
@field:Json(name = "charge_card_id")
lateinit var chargeCardId: String // GE charge card ID
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceTariff
if (provider != other.provider) return false
if (name != other.name) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (supportedCountries != other.supportedCountries) return false
if (chargeCardId != other.chargeCardId) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + supportedCountries.hashCode()
result = 31 * result + chargeCardId.hashCode()
return result
}
@Resource("tariff")
@Parcelize
@JsonClass(generateAdapter = true)
data class ChargepriceTariff(
@Id val id_: String?,
val provider: String,
val name: String,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
@Json(name = "supported_countries")
val supportedCountries: Set<String>,
@Json(name = "charge_card_id")
val chargeCardId: String?, // GE charge card ID
) : Parcelable {
val id: String
get() = id_!!
}
@JsonApi(type = "car")
class ChargepriceCar : Resource(), Equatable {
lateinit var name: String
lateinit var brand: String
@JsonClass(generateAdapter = true)
@Resource("car")
@Parcelize
data class ChargepriceCar(
@Id val id_: String?,
val name: String,
val brand: String,
@field:Json(name = "dc_charge_ports")
lateinit var dcChargePorts: List<String>
lateinit var manufacturer: HasOne<ChargepriceBrand>
@Json(name = "dc_charge_ports")
val dcChargePorts: List<String>
) : Equatable, Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceCar
if (name != other.name) return false
if (brand != other.brand) return false
if (dcChargePorts != other.dcChargePorts) return false
if (manufacturer != other.manufacturer) return false
return true
companion object {
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
)
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + brand.hashCode()
result = 31 * result + dcChargePorts.hashCode()
result = 31 * result + manufacturer.hashCode()
return result
}
val id: String
get() = id_!!
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")
class ChargepriceBrand : Resource()
@JsonClass(generateAdapter = true)
@Resource("brand")
@Parcelize
data class ChargepriceBrand(
@Id val id: String?
) : Parcelable
@JsonApi(type = "charge_price")
class ChargePrice : Resource(), Equatable, Cloneable {
lateinit var provider: String
@JsonClass(generateAdapter = true)
@Resource("charge_price")
@Parcelize
data class ChargePrice(
val provider: String,
@Json(name = "tariff_name")
val tariffName: String,
val url: String,
@Json(name = "monthly_min_sales")
val monthlyMinSales: Double = 0.0,
@Json(name = "total_monthly_fee")
val totalMonthlyFee: Double = 0.0,
@Json(name = "flat_rate")
val flatRate: Boolean = false,
@field:Json(name = "tariff_name")
lateinit var tariffName: String
lateinit var url: String
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@field:Json(name = "monthly_min_sales")
var monthlyMinSales: Double = 0.0
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
val currency: String,
@field:Json(name = "total_monthly_fee")
var totalMonthlyFee: Double = 0.0
@Json(name = "start_time")
val startTime: Int = 0,
val tags: List<ChargepriceTag>,
@field:Json(name = "flat_rate")
var flatRate: Boolean = false
@Json(name = "charge_point_prices")
val chargepointPrices: List<ChargepointPrice>,
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
lateinit var currency: String
@field:Json(name = "start_time")
var startTime: Int = 0
lateinit var tags: List<ChargepriceTag>
@field:Json(name = "charge_point_prices")
lateinit var chargepointPrices: List<ChargepointPrice>
@field:Json(name = "branding")
var branding: ChargepriceBranding? = null
var tariff: HasOne<ChargepriceTariff>? = null
@Json(name = "branding")
val branding: ChargepriceBranding? = null,
@RelationshipsObject
val relationships: @WriteWith<RelationshipsParceler>() Relationships? = null,
) : Equatable, Cloneable, Parcelable {
val tariffId: String?
get() = (relationships?.get("tariff") as? Relationship.ToOne)?.data?.id
fun formatMonthlyFees(ctx: Context): String {
return listOfNotNull(
@@ -221,77 +199,82 @@ class ChargePrice : Resource(), Equatable, Cloneable {
} else null
).joinToString(", ")
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
/**
* Parceler implementation for the Relationships object.
* Note that this ignores certain fields that we don't need (links, meta, etc.)
*/
internal object RelationshipsParceler : Parceler<Relationships?> {
override fun create(parcel: Parcel): Relationships? {
if (parcel.readInt() == 0) return null
other as ChargePrice
val nMembers = parcel.readInt()
val members = (0 until nMembers).map { _ ->
val key = parcel.readString()!!
val value = if (parcel.readInt() == 0) {
val type = parcel.readString()
val id = parcel.readString()
val ri = if (type != null && id != null) {
ResourceIdentifier(type, id)
} else null
Relationship.ToOne(ri)
} else {
val size = parcel.readInt()
val ris = (0 until size).map { _ ->
val type = parcel.readString()!!
val id = parcel.readString()!!
ResourceIdentifier(type, id)
}
Relationship.ToMany(ris)
}
key to value
}.toMap()
if (provider != other.provider) return false
if (tariffName != other.tariffName) return false
if (url != other.url) return false
if (monthlyMinSales != other.monthlyMinSales) return false
if (totalMonthlyFee != other.totalMonthlyFee) return false
if (flatRate != other.flatRate) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (currency != other.currency) return false
if (startTime != other.startTime) return false
if (tags != other.tags) return false
if (chargepointPrices != other.chargepointPrices) return false
if (branding != other.branding) return false
return true
return Relationships(members)
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + tariffName.hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + monthlyMinSales.hashCode()
result = 31 * result + totalMonthlyFee.hashCode()
result = 31 * result + flatRate.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + currency.hashCode()
result = 31 * result + startTime
result = 31 * result + tags.hashCode()
result = 31 * result + chargepointPrices.hashCode()
result = 31 * result + branding.hashCode()
return result
}
public override fun clone(): ChargePrice {
return ChargePrice().apply {
chargepointPrices = this@ChargePrice.chargepointPrices
currency = this@ChargePrice.currency
directPayment = this@ChargePrice.directPayment
flatRate = this@ChargePrice.flatRate
monthlyMinSales = this@ChargePrice.monthlyMinSales
provider = this@ChargePrice.provider
providerCustomerTariff = this@ChargePrice.providerCustomerTariff
startTime = this@ChargePrice.startTime
tags = this@ChargePrice.tags
tariffName = this@ChargePrice.tariffName
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
url = this@ChargePrice.url
tariff = this@ChargePrice.tariff
branding = this@ChargePrice.branding
override fun Relationships?.write(parcel: Parcel, flags: Int) {
if (this == null) {
parcel.writeInt(0)
return
} else {
parcel.writeInt(1)
}
parcel.writeInt(members.size)
for (member in this.members) {
parcel.writeString(member.key)
when (val value = member.value) {
is Relationship.ToOne -> {
parcel.writeInt(0)
parcel.writeString(value.data?.type)
parcel.writeString(value.data?.id)
}
is Relationship.ToMany -> {
parcel.writeInt(1)
parcel.writeInt(value.data.size)
for (ri in value.data) {
parcel.writeString(ri.type)
parcel.writeString(ri.id)
}
}
}
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepointPrice(
val power: Double,
val plug: String,
val price: Double,
val price: Double?,
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
@Json(name = "no_price_reason") var noPriceReason: String?
) {
) : Parcelable {
fun formatDistribution(ctx: Context): String {
fun percent(value: Double): String {
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
@@ -334,19 +317,28 @@ data class ChargepointPrice(
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
)
) : Parcelable
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
val isOnlyKwh =
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
@JsonClass(generateAdapter = true)
@Parcelize
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) :
Parcelable {
val isOnlyKwh
get() = kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
}
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable,
Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
@@ -360,13 +352,97 @@ enum class ChargepriceInclude {
EXCLUSIVE
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
)
) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,
val energy: Double,
val duration: Double
)
)
@Resource("user_feedback")
sealed class ChargepriceUserFeedback(
val notes: String,
val email: String,
val context: String,
val language: String
) {
init {
if (email.isBlank() || email.length > 100 || !Patterns.EMAIL_ADDRESS.matcher(email)
.matches()
) {
throw IllegalArgumentException("invalid email")
}
if (!ChargepriceApi.supportedLanguages.contains(language)) {
throw IllegalArgumentException("invalid language")
}
if (context.length > 500) throw IllegalArgumentException("invalid context")
if (notes.length > 1000) throw IllegalArgumentException("invalid notes")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_price")
class ChargepriceMissingPriceFeedback(
val tariff: String,
val cpo: String,
val price: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.isBlank() || tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (price.isBlank() || price.length > 100) throw IllegalArgumentException("invalid price")
if (poiLink.isBlank() || poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "wrong_price")
class ChargepriceWrongPriceFeedback(
val tariff: String,
val cpo: String,
@Json(name = "displayed_price") val displayedPrice: String,
@Json(name = "actual_price") val actualPrice: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (displayedPrice.length > 100) throw IllegalArgumentException("invalid displayedPrice")
if (actualPrice.length > 100) throw IllegalArgumentException("invalid actualPrice")
if (poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_vehicle")
class ChargepriceMissingVehicleFeedback(
val brand: String,
val model: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (brand.length > 100) throw IllegalArgumentException("invalid brand")
if (model.length > 100) throw IllegalArgumentException("invalid model")
}
}

View File

@@ -0,0 +1,13 @@
package net.vonforst.evmap.api.fronyx
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.ZonedDateTime
internal class ZonedDateTimeAdapter {
@FromJson
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
@ToJson
fun toJson(value: ZonedDateTime): String = value.toString()
}

View File

@@ -0,0 +1,87 @@
package net.vonforst.evmap.api.fronyx
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface FronyxApi {
@GET("predictions/evse-id/{evseId}")
suspend fun getPredictionsForEvseId(
@Path("evseId") evseId: String,
@Query("timeframe") timeframe: Int? = null
): FronyxEvseIdResponse
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
private val moshi = Moshi.Builder()
.add(ZonedDateTimeAdapter())
.build()
fun create(
apikey: String,
baseurl: String = "https://api.fronyx.io/api/",
context: Context? = null
): FronyxApi {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
val original = chain.request()
val new = original.newBuilder()
.header("X-API-Token", apikey)
.header("Content-Type", "application/json")
.build()
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
}
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(FronyxApi::class.java)
}
/**
* Checks if a chargepoint is supported by Fronyx.
*
* This function just applies some heuristics on the charger's data without making API
* calls. If it returns true, that is not a guarantee that Fronyx will have information
* on this chargepoint. But if it is false, it is pretty unlikely that Fronyx will have
* useful data, so we do not try to load the data in this case.
*/
fun isChargepointSupported(charger: ChargeLocation, chargepoint: Chargepoint): Boolean {
if (charger.address?.country !in listOf("Deutschland", "Germany")) {
// fronyx only predicts for chargers in Germany for now
return false
}
if (chargepoint.type !in listOf(
Chargepoint.CCS_UNKNOWN,
Chargepoint.CCS_TYPE_2,
Chargepoint.CHADEMO
)
) {
// fronyx only predicts DC chargers for now
return false
}
return true
}
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.api.fronyx
import com.squareup.moshi.JsonClass
import java.time.ZonedDateTime
@JsonClass(generateAdapter = true)
data class FronyxEvseIdResponse(
val evseId: String,
val predictions: List<FronyxPrediction>
)
@JsonClass(generateAdapter = true)
data class FronyxPrediction(
val timestamp: ZonedDateTime,
val status: FronyxStatus
)
enum class FronyxStatus {
AVAILABLE, UNAVAILABLE
}

View File

@@ -21,50 +21,51 @@ import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.*
import java.io.IOException
interface GoingElectricApi {
@GET("chargepoints/")
@FormUrlEncoded
@POST("chargepoints/")
suspend fun getChargepoints(
@Query("sw_lat") sw_lat: Double, @Query("sw_lng") sw_lng: Double,
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
@Query("zoom") zoom: Float,
@Query("clustering") clustering: Boolean = false,
@Query("cluster_distance") clusterDistance: Int? = null,
@Query("freecharging") freecharging: Boolean = false,
@Query("freeparking") freeparking: Boolean = false,
@Query("min_power") minPower: Int = 0,
@Query("plugs") plugs: String? = null,
@Query("chargecards") chargecards: String? = null,
@Query("networks") networks: String? = null,
@Query("categories") categories: String? = null,
@Query("startkey") startkey: Int? = null,
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
@Field("sw_lat") sw_lat: Double, @Field("sw_lng") sw_lng: Double,
@Field("ne_lat") ne_lat: Double, @Field("ne_lng") ne_lng: Double,
@Field("zoom") zoom: Float,
@Field("clustering") clustering: Boolean = false,
@Field("cluster_distance") clusterDistance: Int? = null,
@Field("freecharging") freecharging: Boolean = false,
@Field("freeparking") freeparking: Boolean = false,
@Field("min_power") minPower: Int = 0,
@Field("plugs") plugs: String? = null,
@Field("chargecards") chargecards: String? = null,
@Field("networks") networks: String? = null,
@Field("categories") categories: String? = null,
@Field("startkey") startkey: Int? = null,
@Field("open_twentyfourseven") open247: Boolean = false,
@Field("barrierfree") barrierfree: Boolean = false,
@Field("exclude_faults") excludeFaults: Boolean = false
): Response<GEChargepointList>
@GET("chargepoints/")
@FormUrlEncoded
@POST("chargepoints/")
suspend fun getChargepointsRadius(
@Query("lat") lat: Double, @Query("lng") lng: Double,
@Query("radius") radius: Int,
@Query("zoom") zoom: Float,
@Query("orderby") orderby: String = "distance",
@Query("clustering") clustering: Boolean = false,
@Query("cluster_distance") clusterDistance: Int? = null,
@Query("freecharging") freecharging: Boolean = false,
@Query("freeparking") freeparking: Boolean = false,
@Query("min_power") minPower: Int = 0,
@Query("plugs") plugs: String? = null,
@Query("chargecards") chargecards: String? = null,
@Query("networks") networks: String? = null,
@Query("categories") categories: String? = null,
@Query("startkey") startkey: Int? = null,
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
@Field("lat") lat: Double, @Field("lng") lng: Double,
@Field("radius") radius: Int,
@Field("zoom") zoom: Float,
@Field("orderby") orderby: String = "distance",
@Field("clustering") clustering: Boolean = false,
@Field("cluster_distance") clusterDistance: Int? = null,
@Field("freecharging") freecharging: Boolean = false,
@Field("freeparking") freeparking: Boolean = false,
@Field("min_power") minPower: Int = 0,
@Field("plugs") plugs: String? = null,
@Field("chargecards") chargecards: String? = null,
@Field("networks") networks: String? = null,
@Field("categories") categories: String? = null,
@Field("startkey") startkey: Int? = null,
@Field("open_twentyfourseven") open247: Boolean = false,
@Field("barrierfree") barrierfree: Boolean = false,
@Field("exclude_faults") excludeFaults: Boolean = false
): Response<GEChargepointList>
@GET("chargepoints/")
@@ -125,9 +126,11 @@ class GoingElectricApiWrapper(
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
) : ChargepointApi<GEReferenceData> {
private val clusterThreshold = 11f
val api = GoingElectricApi.create(apikey, baseurl, context)
override fun getName() = "GoingElectric.de"
override val name = "GoingElectric.de"
override val id = "going_electric"
override suspend fun getChargepoints(
referenceData: ReferenceData,
@@ -172,7 +175,7 @@ class GoingElectricApiWrapper(
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -266,7 +269,7 @@ class GoingElectricApiWrapper(
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
@@ -326,10 +329,10 @@ class GoingElectricApiWrapper(
} else {
true
}
}.map { it.convert(apikey) }
}.map { it.convert(apikey, false) }
// apply clustering
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
if (!geClusteringAvailable && useClustering) {
@@ -350,7 +353,7 @@ class GoingElectricApiWrapper(
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
apikey
apikey, true
)
)
} else {
@@ -465,17 +468,17 @@ class GoingElectricApiWrapper(
sp.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
sp.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults"),
BooleanFilter(sp.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
sp.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults")
MultipleChoiceFilter(
sp.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
)
)
}
}

View File

@@ -29,7 +29,7 @@ data class GEChargeCardList(
)
sealed class GEChargepointListItem {
abstract fun convert(apikey: String): ChargepointListItem
abstract fun convert(apikey: String, isDetailed: Boolean): ChargepointListItem
}
@JsonClass(generateAdapter = true)
@@ -54,7 +54,7 @@ data class GEChargeLocation(
val openinghours: GEOpeningHours?,
val cost: GECost?
) : GEChargepointListItem() {
override fun convert(apikey: String) = ChargeLocation(
override fun convert(apikey: String, isDetailed: Boolean) = ChargeLocation(
id,
"goingelectric",
name,
@@ -76,7 +76,9 @@ data class GEChargeLocation(
openinghours?.convert(),
cost?.convert(),
null,
ChargepriceData(address.country, network, chargepoints.map { it.type })
ChargepriceData(address.country, network, chargepoints.map { it.type }),
Instant.now(),
isDetailed
)
}
@@ -161,7 +163,7 @@ data class GEChargeLocationCluster(
val clusterCount: Int,
val coordinates: GECoordinate
) : GEChargepointListItem() {
override fun convert(apikey: String) =
override fun convert(apikey: String, isDetailed: Boolean) =
ChargeLocationCluster(clusterCount, coordinates.convert())
}

View File

@@ -2,12 +2,20 @@ package net.vonforst.evmap.api.openchargemap
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeParseException
internal class ZonedDateTimeAdapter {
@FromJson
fun fromJson(value: String?): ZonedDateTime? = value?.let {
ZonedDateTime.parse(value)
try {
ZonedDateTime.parse(value)
} catch (e: DateTimeParseException) {
val dt: LocalDateTime = LocalDateTime.parse(value)
dt.atZone(ZoneOffset.UTC)
}
}
@ToJson

View File

@@ -105,9 +105,11 @@ class OpenChargeMapApiWrapper(
baseurl: String = "https://api.openchargemap.io/v3/",
context: Context? = null
) : ChargepointApi<OCMReferenceData> {
private val clusterThreshold = 11
val api = OpenChargeMapApi.create(apikey, baseurl, context)
override fun getName() = "OpenChargeMap.org"
override val name = "OpenChargeMap.org"
override val id = "open_charge_map"
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
if (value == null || value.all) null else value.values.joinToString(",")
@@ -235,10 +237,10 @@ class OpenChargeMapApiWrapper(
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
}.map { it.convert(referenceData) }.distinct() as List<ChargepointListItem>
}.map { it.convert(referenceData, false) }.distinct() as List<ChargepointListItem>
// apply clustering
val useClustering = zoom < 13
val useClustering = zoom < clusterThreshold
if (useClustering) {
val clusterDistance = getClusterDistance(zoom)
Dispatchers.IO.run {
@@ -256,7 +258,7 @@ class OpenChargeMapApiWrapper(
try {
val response = api.getChargepointDetail(id)
if (response.isSuccessful && response.body()?.size == 1) {
return Resource.success(response.body()!![0].convert(referenceData))
return Resource.success(response.body()!![0].convert(referenceData, true))
} else {
return Resource.error(response.message(), null)
}

View File

@@ -7,6 +7,7 @@ import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.max
import net.vonforst.evmap.model.*
import java.time.Instant
import java.time.ZonedDateTime
// Unknown, Currently Available, Currently In Use, Operational
@@ -44,7 +45,7 @@ data class OCMChargepoint(
@Json(name = "UserComments") val userComments: List<OCMUserComment>?,
@Json(name = "DateLastStatusUpdate") val lastStatusUpdateDate: ZonedDateTime?
) {
fun convert(refData: OCMReferenceData) = ChargeLocation(
fun convert(refData: OCMReferenceData, isDetailed: Boolean) = ChargeLocation(
id,
"openchargemap",
addressInfo.title,
@@ -69,7 +70,9 @@ data class OCMChargepoint(
ChargepriceData(
addressInfo.countryISOCode(refData),
operatorId?.toString(),
connections.map { "${it.connectionTypeId},${it.currentTypeId}" })
connections.map { "${it.connectionTypeId},${it.currentTypeId}" }),
Instant.now(),
isDetailed
)
private fun convertFaultReport(): FaultReport? {
@@ -138,7 +141,7 @@ data class OCMConnection(
) {
fun convert(refData: OCMReferenceData) = Chargepoint(
convertConnectionTypeFromOCM(connectionTypeId, refData),
power ?: 0.0,
power,
quantity ?: 1
)

View File

@@ -0,0 +1,202 @@
package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.model.*
import okhttp3.internal.immutableListOf
import java.time.Instant
import java.time.ZonedDateTime
private data class OsmSocket(
// The OSM socket name (e.g. "type2_combo")
val osmSocketName: String,
// The socket identifier used in EVMap.
// TODO: This should probably be a separate enum-like type, not a string.
val evmapKey: String?,
) {
/**
* Return the OSM socket base tag (e.g. "socket:type2_combo").
*/
fun osmSocketBaseTag(): String {
return "socket:${this.osmSocketName}"
}
}
// List of all OSM socket types that are relevant for EVs:
// https://wiki.openstreetmap.org/wiki/Key:socket
private val SOCKET_TYPES = immutableListOf(
// Type 1
OsmSocket("type1", Chargepoint.TYPE_1),
OsmSocket("type1_combo", Chargepoint.CCS_TYPE_1),
// Type 2
OsmSocket("type2", Chargepoint.TYPE_2_SOCKET), // Type2 socket (or unknown)
OsmSocket("type2_cable", Chargepoint.TYPE_2_PLUG), // Type2 plug
OsmSocket("type2_combo", Chargepoint.CCS_TYPE_2), // CCS
// CHAdeMO
OsmSocket("chademo", Chargepoint.CHADEMO),
// Tesla
OsmSocket("tesla_standard", null),
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
// CEE
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
OsmSocket("cee_red_16a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_32a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_63a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_125a", Chargepoint.CEE_ROT),
// Europe
OsmSocket("schuko", Chargepoint.SCHUKO),
// Switzerland
OsmSocket("sev1011_t13", null),
OsmSocket("sev1011_t15", null),
OsmSocket("sev1011_t23", null),
OsmSocket("sev1011_t25", null),
)
@JsonClass(generateAdapter = true)
data class OSMChargingStation(
// Unique numeric ID
val id: Long,
// Latitude (WGS84)
val lat: Double,
// Longitude (WGS84)
val lon: Double,
// Timestamp of last update
@Json(name = "timestamp") val lastUpdateTimestamp: ZonedDateTime,
// Numeric, monotonically increasing version number
val version: Int,
// User that last modified this POI
val user: String,
// Raw key-value OSM tags
val tags: Map<String, String>,
) {
/**
* Convert the [OSMChargingStation] to a generic [ChargeLocation].
*
* The [dataFetchTimestamp] should be set to the timestamp when the data was last
* refreshed / fetched from OSM. It will always be later than the [lastUpdateTimestamp],
* which contains the timestamp when the data was last _edited_ in OSM.
*/
fun convert(dataFetchTimestamp: Instant) = ChargeLocation(
id,
"openstreetmap",
getName(),
Coordinate(lat, lon),
null, // TODO: Can we determine this with overpass?
getChargepoints(),
tags["network"],
"https://www.openstreetmap.org/node/$id",
"https://www.openstreetmap.org/edit?node=$id",
null,
false, // We don't know
tags["authentication:none"] == "yes",
tags["operator"],
tags["description"],
null,
null,
null,
null,
getOpeningHours(),
getCost(),
"© OpenStreetMap contributors",
null,
dataFetchTimestamp,
true,
)
/**
* Return the name for this charging station.
*/
private fun getName(): String {
// Ideally this station has a name.
// If not, fall back to the operator.
// If that is missing as well, use a generic "Charging Station" string.
return tags["name"]
?: tags["operator"]
?: "Charging Station";
}
/**
* Return the chargepoints for this charging station.
*/
private fun getChargepoints(): List<Chargepoint> {
// Note: In OSM, the chargepoints are mapped as "socket:<type> = <count>"
val chargepoints = mutableListOf<Chargepoint>()
for (socket in SOCKET_TYPES) {
val count = try {
(this.tags[socket.osmSocketBaseTag()] ?: "0").toInt()
} catch (e: NumberFormatException) {
0
}
if (count > 0) {
if (socket.evmapKey != null) {
val outputPower = parseOutputPower(this.tags["${socket.osmSocketBaseTag()}:output"])
chargepoints.add(Chargepoint(socket.evmapKey, outputPower, count))
}
}
}
return chargepoints
}
private fun getOpeningHours(): OpeningHours? {
val rawOpeningHours = tags["opening_hours"] ?: return null
// Handle the simple 24/7 case
if (rawOpeningHours == "24/7") {
return OpeningHours(true, null, null)
}
// TODO: Try to convert other formats as well?
//
// Note: The current {@link OpeningHours} format is not flexible enough to handle
// all rules that OSM can represent and might need to be updated.
// This library could help: https://github.com/simonpoole/OpeningHoursParser
//
// Alternatively, with the opening-hours-evaluator library
// https://github.com/leonardehrenfried/opening-hours-evaluator
// we could implement an "open now" feature.
return null
}
private fun getCost(): Cost? {
val freecharging = when (tags["fee"]?.lowercase()) {
"yes", "y" -> false
"no", "n" -> true
else -> null
}
val freeparking = when (tags["parking:fee"]?.lowercase()) {
"no", "n" -> true
"yes", "y", "interval" -> false
else -> null
}
return Cost(freecharging, freeparking)
}
companion object {
/**
* Parse raw OSM output power.
*
* The proper format to map output power for an EV charging station is "<amount> kW",
* for example "22 kW" or "3.7 kW". Some fields in the wild are tagged with the unit "kVA"
* instead of "kW", those can be treated as equivalent.
*
* Sometimes people also mapped plain numbers (e.g. 7000, I assume that's 7 kW),
* ranges (5,5 - 11 kW, huh?) or even current (32 A), which is wrong. If we cannot parse,
* just ignore the field.
*/
fun parseOutputPower(rawOutput: String?): Double? {
if (rawOutput == null) {
return null;
}
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
val matchResult = pattern.matchEntire(rawOutput) ?: return null
val numberString = matchResult.groupValues[1].replace(',', '.')
return numberString.toDoubleOrNull()
}
}
}

View File

@@ -29,7 +29,7 @@ class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0].language)
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0]?.language)
accessToken(context.getString(R.string.mapbox_key))
autocomplete(true)
this.query(query)

View File

@@ -2,7 +2,10 @@ package net.vonforst.evmap.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -10,9 +13,11 @@ import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialContainerTransform
import net.vonforst.evmap.MapsActivity
@@ -20,24 +25,31 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.viewModelFactory
import net.vonforst.evmap.viewmodel.savedStateViewModelFactory
import java.text.NumberFormat
class ChargepriceFragment : Fragment() {
private lateinit var binding: FragmentChargepriceBinding
private lateinit var headerBinding: FragmentChargepriceHeaderBinding
private var connectionErrorSnackbar: Snackbar? = null
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
viewModelFactory {
savedStateViewModelFactory { state ->
ChargepriceViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url),
state
)
}
})
@@ -45,6 +57,33 @@ class ChargepriceFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform()
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter - 30).mod(50) == 0) {
showDonationDialog()
}
}
}
override fun onResume() {
super.onResume()
vm.reloadPrefs()
}
private fun showDonationDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.chargeprice_donation_dialog_title)
.setMessage(R.string.chargeprice_donation_dialog_detail)
.setNegativeButton(R.string.ok) { di, _ ->
di.cancel()
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
}
.show()
}
override fun onCreateView(
@@ -56,8 +95,14 @@ class ChargepriceFragment : Fragment() {
inflater,
R.layout.fragment_chargeprice, container, false
)
headerBinding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice_header, container, false
)
binding.lifecycleOwner = this
binding.vm = vm
headerBinding.lifecycleOwner = this
headerBinding.vm = vm
binding.toolbar.inflateMenu(R.menu.chargeprice)
binding.toolbar.setTitle(R.string.chargeprice_title)
@@ -76,15 +121,13 @@ class ChargepriceFragment : Fragment() {
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
val charger = fragmentArgs.charger
val dataSource = fragmentArgs.dataSource
vm.charger.value = charger
vm.dataSource.value = dataSource
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged.get(0)
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
binding.vehicleSelection.adapter = vehicleAdapter
headerBinding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar> = Observer {
vehicleAdapter.setCheckedItem(it)
}
@@ -100,8 +143,12 @@ class ChargepriceFragment : Fragment() {
(requireActivity() as MapsActivity).openUrl(it.url)
}
}
val joinedAdapter = ConcatAdapter(
SingleViewAdapter(headerBinding.root),
chargepriceAdapter
)
binding.chargePricesList.apply {
adapter = chargepriceAdapter
adapter = joinedAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -119,6 +166,9 @@ class ChargepriceFragment : Fragment() {
vm.myTariffsAll.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.submitList(it.data)
}
val connectorsAdapter = CheckableConnectorAdapter()
@@ -137,25 +187,25 @@ class ChargepriceFragment : Fragment() {
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
}
binding.connectorsList.apply {
headerBinding.connectorsList.apply {
adapter = connectorsAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${dataSource}")
(requireActivity() as MapsActivity).openUrl(ChargepriceApi.getPoiUrl(charger))
}
binding.btnSettings.setOnClickListener {
findNavController().navigate(R.id.action_chargeprice_to_settingsFragment)
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
}
binding.batteryRange.setLabelFormatter { value: Float ->
headerBinding.batteryRange.setLabelFormatter { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
binding.batteryRange.setOnTouchListener { _: View, motionEvent: MotionEvent ->
headerBinding.batteryRange.setOnTouchListener { _: View, motionEvent: MotionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> vm.batteryRangeSliderDragging.value = true
MotionEvent.ACTION_UP -> vm.batteryRangeSliderDragging.value = false

View File

@@ -4,13 +4,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import net.vonforst.evmap.databinding.DialogDataSourceSelectBinding
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
import net.vonforst.evmap.ui.MaterialDialogFragment
class DataSourceSelectDialog : AppCompatDialogFragment() {
class DataSourceSelectDialog : MaterialDialogFragment() {
private lateinit var binding: DialogDataSourceSelectBinding
var okListener: ((String) -> Unit)? = null
@@ -28,7 +27,7 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
}
}
override fun onCreateView(
override fun createView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
@@ -41,16 +40,12 @@ class DataSourceSelectDialog : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
// dialog with 95% screen height
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
setFullSize()
}
private lateinit var prefs: PreferenceDataSource
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
override fun initView(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
binding.btnCancel.visibility =
if (args.getBoolean("cancel_enabled")) View.VISIBLE else View.GONE

View File

@@ -20,23 +20,24 @@ import com.car2go.maps.model.LatLng
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialFadeThrough
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
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.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.model.Favorite
import net.vonforst.evmap.model.FavoriteWithDetail
import net.vonforst.evmap.utils.checkAnyLocationPermission
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
class FavoritesFragment : Fragment() {
private lateinit var binding: FragmentFavoritesBinding
private var locationClient: LostApiClient? = null
private var toDelete: ChargeLocation? = null
private lateinit var locationEngine: LocationEngine
private var toDelete: Favorite? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
@@ -51,8 +52,8 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this).build()
locationEngine = FusionEngine(requireContext())
enterTransition = MaterialFadeThrough()
exitTransition = MaterialFadeThrough()
@@ -84,7 +85,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
)
adapter = FavoritesAdapter(onDelete = {
delete(it.charger)
delete(it.fav)
}).apply {
onClickListener = {
findNavController().navigate(
@@ -108,42 +109,38 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
createTouchHelper().attachToRecyclerView(binding.favsList)
locationClient!!.connect()
}
override fun onConnected() {
val context = this.context ?: return
if (context.checkAnyLocationPermission()) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient!!)
if (location != null) {
vm.location.value = LatLng(location.latitude, location.longitude)
binding.swipeRefresh.setOnRefreshListener {
vm.reloadAvailability() {
binding.swipeRefresh.isRefreshing = false
}
}
}
override fun onConnectionSuspended() {
override fun onStart() {
super.onStart()
}
override fun onDestroy() {
super.onDestroy()
locationClient?.let {
if (it.isConnected) it.disconnect()
if (requireContext().checkAnyLocationPermission()) {
val location = locationEngine.getLastKnownLocation()
location?.let {
vm.location.value = LatLng(it.latitude, it.longitude)
}
}
}
fun delete(fav: ChargeLocation) {
val position = vm.listData.value?.indexOfFirst { it.charger == fav } ?: return
fun delete(fav: FavoriteWithDetail) {
val position =
vm.listData.value?.indexOfFirst { it.fav.favorite.favoriteId == fav.favorite.favoriteId }
?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fav
toDelete = fav.favorite
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fav.name),
getString(R.string.deleted_filterprofile, fav.charger.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null
@@ -182,7 +179,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val fav = vm.favorites.value?.find { it.id == viewHolder.itemId }
val fav = vm.favorites.value?.find { it.favorite.favoriteId == viewHolder.itemId }
fav?.let { delete(it) }
}

View File

@@ -3,9 +3,11 @@ package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
@@ -22,7 +24,7 @@ import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterViewModel
class FilterFragment : Fragment() {
class FilterFragment : Fragment(), MenuProvider {
private lateinit var binding: FragmentFilterBinding
private val vm: FilterViewModel by viewModels()
@@ -40,9 +42,6 @@ class FilterFragment : Fragment() {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_filter, container, false)
binding.lifecycleOwner = this
binding.vm = vm
setHasOptionsMenu(true)
vm.filterProfile.observe(viewLifecycleOwner) {}
return binding.root
@@ -50,6 +49,7 @@ class FilterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
binding.toolbar.setupWithNavController(
findNavController(),
@@ -81,12 +81,11 @@ class FilterFragment : Fragment() {
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.filter, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_apply -> {
lifecycleScope.launch {
@@ -99,7 +98,7 @@ class FilterFragment : Fragment() {
saveProfile()
true
}
else -> super.onOptionsItemSelected(item)
else -> false
}
}

View File

@@ -4,11 +4,9 @@ import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
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.*
@@ -18,11 +16,11 @@ import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.view.*
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
@@ -51,57 +49,57 @@ import com.car2go.maps.model.BitmapDescriptor
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.Marker
import com.car2go.maps.model.MarkerOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike.*
import com.mahc.custombottomsheetbehavior.MergedAppBarLayoutBehavior
import com.mapzen.android.lost.api.LocationListener
import com.mapzen.android.lost.api.LocationRequest
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
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.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.autocomplete.ApiUnavailableException
import net.vonforst.evmap.autocomplete.PlaceWithBounds
import net.vonforst.evmap.bold
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.location.FusionEngine
import net.vonforst.evmap.location.LocationEngine
import net.vonforst.evmap.location.Priority
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.ui.*
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
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.contains
import kotlin.collections.set
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks, LocationListener {
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels()
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
private lateinit var locationClient: LostApiClient
private lateinit var locationEngine: LocationEngine
private var requestingLocationUpdates = false
private lateinit var bottomSheetBehavior: BottomSheetBehaviorGoogleMapsLike<View>
private lateinit var detailAppBarBehavior: MergedAppBarLayoutBehavior
@@ -112,6 +110,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var searchResultIcon: BitmapDescriptor? = null
private var connectionErrorSnackbar: Snackbar? = null
private var previousChargepointIds: Set<Long>? = null
private var mapTopPadding: Int = 0
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -131,7 +130,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val state = bottomSheetBehavior.state
if (state != STATE_COLLAPSED && state != STATE_HIDDEN) {
bottomSheetBehavior.state = STATE_COLLAPSED
if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
} else if (state == STATE_COLLAPSED) {
vm.chargerSparse.value = null
} else if (state == STATE_HIDDEN) {
@@ -145,10 +148,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
prefs = PreferenceDataSource(requireContext())
locationClient = LostApiClient.Builder(requireContext())
.addConnectionCallbacks(this)
.build()
locationClient.connect()
locationEngine = FusionEngine(requireContext())
clusterIconGenerator = ClusterIconGenerator(requireContext())
enterTransition = MaterialFadeThrough()
@@ -169,7 +169,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val provider = prefs.mapProvider
if (mapFragment == null) {
mapFragment =
requireActivity().supportFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
childFragmentManager.findFragmentByTag(mapFragmentTag) as MapFragment?
}
if (mapFragment == null || mapFragment!!.priority[0] != provider) {
mapFragment = MapFragment()
@@ -182,7 +182,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MapFragment.GOOGLE,
MapFragment.MAPBOX
)
requireActivity().supportFragmentManager
childFragmentManager
.beginTransaction()
.replace(R.id.map, mapFragment!!, mapFragmentTag)
.commit()
@@ -195,21 +195,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
searchResultIcon = null
}
setHasOptionsMenu(true)
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { v, insets ->
ViewCompat.onApplyWindowInsets(binding.root, insets)
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.systemWindowInsetTop
topMargin = systemWindowInsetTop
}
// margin of layers button
// margin of layers button: status bar height + toolbar height + margin
val density = resources.displayMetrics.density
// status bar height + toolbar height + margin
val margin =
if (binding.toolbarContainer.layoutParams.width == ViewGroup.LayoutParams.MATCH_PARENT) {
insets.systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
} else {
insets.systemWindowInsetTop + (12 * density).toInt()
systemWindowInsetTop + (12 * density).toInt()
}
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
@@ -217,6 +219,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
}
// set map padding so that compass is not obstructed by toolbar
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
// if we actually use map.setPadding here, Mapbox will re-trigger onApplyWindowInsets
// and cause an infinite loop. So we rely on onMapReady being called later than
// onApplyWindowInsets.
insets
}
@@ -231,7 +240,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return binding.root
}
val bottomSheetCollapsible
get() = resources.getBoolean(R.bool.bottom_sheet_collapsible)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
mapFragment!!.getMapAsync(this)
bottomSheetBehavior = BottomSheetBehaviorGoogleMapsLike.from(binding.bottomSheet)
detailAppBarBehavior = MergedAppBarLayoutBehavior.from(binding.detailAppBar)
@@ -244,6 +258,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
setupObservers()
setupClickListeners()
@@ -306,19 +321,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.reloadPrefs()
if (requestingLocationUpdates && requireContext().checkAnyLocationPermission()
&& locationClient.isConnected
) {
requestLocationUpdates()
}
}
@SuppressLint("MissingPermission")
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
val context = context ?: return@registerForActivityResult
if (context.checkAnyLocationPermission()) {
enableLocation(moveTo = true, animate = true)
}
}
private fun setupClickListeners() {
binding.fabLocate.setOnClickListener {
if (!requireContext().checkFineLocationPermission()) {
ActivityCompat.requestPermissions(
requireActivity(),
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION),
REQUEST_LOCATION_PERMISSION
requestPermissionLauncher.launch(
arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
)
}
if (requireContext().checkAnyLocationPermission()) {
@@ -347,25 +368,33 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
val dataSource = when (vm.apiType) {
GoingElectricApiWrapper::class.java -> "going_electric"
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
else -> throw IllegalArgumentException("unsupported data source")
}
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragmentArgs(charger, dataSource).toBundle(),
ChargepriceFragmentArgs(charger).toBundle(),
null, extras
)
}
binding.detailView.imgPredictionSource.setOnClickListener {
(activity as? MapsActivity)?.openUrl(getString(R.string.fronyx_url))
}
binding.detailView.btnPredictionHelp.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.prediction_help))
.setPositiveButton(R.string.ok) { _, _ -> }
.show()
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
setupSearchAutocomplete()
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
if (bottomSheetCollapsible) {
bottomSheetBehavior.state = STATE_COLLAPSED
} else {
vm.chargerSparse.value = null
}
}
binding.detailAppBar.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
@@ -384,7 +413,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val charger = vm.charger.value?.data
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl)
if (vm.apiType == GoingElectricApiWrapper::class.java) {
if (vm.apiId.value == "going_electric") {
// instructions specific to GoingElectric
Toast.makeText(
requireContext(),
@@ -398,6 +427,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
else -> false
}
}
binding.detailView.btnRefreshLiveData.setOnClickListener {
vm.reloadAvailability()
}
}
var searchKeyListener: KeyListener? = null
@@ -499,9 +531,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun toggleFavorite() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
val isFav = favs.find { it.id == charger.id } != null
if (isFav) {
vm.deleteFavorite(charger)
val fav = favs.find { it.charger.id == charger.id }
if (fav != null) {
vm.deleteFavorite(fav.favorite)
} else {
vm.insertFavorite(charger)
}
@@ -511,7 +543,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = !isFav
fav = fav == null,
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -540,7 +573,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.chargerSparse.observe(viewLifecycleOwner, Observer {
if (it != null) {
if (vm.bottomSheetState.value != BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT) {
bottomSheetBehavior.state = STATE_COLLAPSED
bottomSheetBehavior.state =
if (bottomSheetCollapsible) STATE_COLLAPSED else STATE_ANCHOR_POINT
}
removeSearchFocus()
binding.fabDirections.show()
@@ -578,38 +612,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateMap(chargepoints)
}
})
vm.useMiniMarkers.observe(viewLifecycleOwner) {
vm.chargepoints.value?.data?.let { updateMap(it) }
}
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
})
vm.searchResult.observe(viewLifecycleOwner, Observer { place ->
val map = this.map ?: return@Observer
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
vm.myLocationEnabled.value = false
if (place.viewport != null) {
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
displaySearchResult(place, moveCamera = true)
})
vm.layersMenuOpen.observe(viewLifecycleOwner, Observer { open ->
binding.fabLayers.visibility = if (open) View.INVISIBLE else View.VISIBLE
@@ -626,6 +636,40 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
updateBackPressedCallback()
}
private fun displaySearchResult(place: PlaceWithBounds?, moveCamera: Boolean) {
val map = this.map ?: return
searchResultMarker?.remove()
searchResultMarker = null
if (place != null) {
// disable location following when search result is shown
if (moveCamera) {
vm.myLocationEnabled.value = false
if (place.viewport != null) {
map.animateCamera(map.cameraUpdateFactory.newLatLngBounds(place.viewport, 0))
} else {
map.animateCamera(map.cameraUpdateFactory.newLatLngZoom(place.latLng, 12f))
}
}
if (searchResultIcon == null) {
searchResultIcon =
map.bitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker)
}
searchResultMarker = map.addMarker(
MarkerOptions()
.z(placeSearchZ)
.position(place.latLng)
.icon(searchResultIcon)
.anchor(0.5f, 1f)
)
} else {
binding.search.setText("")
}
updateBackPressedCallback()
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
@@ -642,7 +686,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.id } ?: emptyList()
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -657,10 +702,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = true,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
animator.animateMarkerBounce(marker)
animator.animateMarkerBounce(marker, vm.useMiniMarkers.value == true)
// un-highlight all other markers
markers.forEach { (m, c) ->
@@ -671,7 +717,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight = false,
fault = c.faultReport != null,
multi = c.isMulti(vm.filteredConnectors.value),
fav = c.id in vm.favorites.value?.map { it.id } ?: emptyList()
fav = c.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
}
@@ -681,7 +728,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private fun updateFavoriteToggle() {
val favs = vm.favorites.value ?: return
val charger = vm.chargerSparse.value ?: return
if (favs.find { it.id == charger.id } != null) {
if (favs.find { it.charger.id == charger.id } != null) {
favToggle.setIcon(R.drawable.ic_fav)
} else {
favToggle.setIcon(R.drawable.ic_fav_no)
@@ -711,6 +758,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
.withStartPosition(position)
.withHiddenStatusBar(false)
.show()
}
@@ -783,7 +831,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
it.name
}
}
AlertDialog.Builder(activity)
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.charge_cards)
.setItems(names.toTypedArray()) { _, i ->
val card = data[i]
@@ -793,9 +841,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onMapReady(map: AnyMap) {
this.map = map
chargerIconGenerator = ChargerIconGenerator(requireContext(), map.bitmapDescriptorFactory)
val context = this.context ?: return
chargerIconGenerator = ChargerIconGenerator(context, map.bitmapDescriptorFactory)
if (BuildConfig.FLAVOR == "google" && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
if (BuildConfig.FLAVOR.contains("google") && mapFragment!!.priority[0] == MapFragment.GOOGLE) {
// Google Maps: icons can be generated in background thread
lifecycleScope.launch {
withContext(Dispatchers.IO) {
@@ -811,17 +860,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
animator = MarkerAnimator(chargerIconGenerator)
map.uiSettings.setTiltGesturesEnabled(false)
map.uiSettings.setRotateGesturesEnabled(prefs.mapRotateGesturesEnabled)
map.setIndoorEnabled(false)
map.uiSettings.setIndoorLevelPickerEnabled(false)
map.setOnCameraIdleListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
vm.reloadChargepoints()
}
map.setOnCameraMoveListener {
vm.mapPosition.value = MapPosition(
map.projection.visibleRegion.latLngBounds, map.cameraPosition.zoom
)
}
vm.mapPosition.observe(viewLifecycleOwner) {
binding.scaleView.update(map.cameraPosition.zoom, map.cameraPosition.target.latitude)
}
map.setOnCameraMoveStartedListener { reason ->
if (reason == AnyMap.OnCameraMoveStartedListener.REASON_GESTURE) {
if (vm.myLocationEnabled.value == true) {
@@ -864,7 +921,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, binding.toolbarContainer.height, 0, 0)
map.setPadding(0, mapTopPadding, 0, 0)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(
@@ -933,7 +990,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
lifecycleScope.launch {
val address = withContext(Dispatchers.IO) {
try {
Geocoder(requireContext()).getFromLocationName(locationName, 1).getOrNull(0)
Geocoder(requireContext()).getFromLocationName(locationName, 1)
?.getOrNull(0)
} catch (e: IOException) {
null
}
@@ -955,7 +1013,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
}
if (context?.checkAnyLocationPermission() ?: false) {
if (context.checkAnyLocationPermission()) {
enableLocation(!positionSet, false)
positionSet = true
}
@@ -972,7 +1030,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
if (vm.searchResult.value != null) {
// show search result (after configuration change)
vm.searchResult.postValue(vm.searchResult.value)
displaySearchResult(vm.searchResult.value, moveCamera = !positionSet)
}
}
@@ -983,16 +1041,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.uiSettings.setMyLocationButtonEnabled(false)
if (moveTo) {
vm.myLocationEnabled.value = true
if (locationClient.isConnected) {
moveToLastLocation(map, animate)
requestLocationUpdates()
}
moveToLastLocation(map, animate)
requestLocationUpdates()
}
}
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun moveToLastLocation(map: AnyMap, animate: Boolean) {
val location = LocationServices.FusedLocationApi.getLastLocation(locationClient)
val location = locationEngine.getLastKnownLocation()
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
vm.location.value = latLng
@@ -1017,15 +1073,18 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// update icons of existing markers (connector filter may have changed)
for ((marker, charger) in markers) {
val highlight = charger.id == vm.chargerSparse.value?.id
marker.setIcon(
chargerIconGenerator.getBitmapDescriptor(
getMarkerTint(charger, vm.filteredConnectors.value),
highlight = charger == vm.chargerSparse.value,
highlight = highlight,
fault = charger.faultReport != null,
multi = charger.isMulti(vm.filteredConnectors.value),
fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList(),
mini = vm.useMiniMarkers.value == true
)
)
marker.setAnchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
}
if (chargers.toSet() != markers.values) {
@@ -1038,11 +1097,15 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
// animate marker if it is visible, otherwise remove immediately
if (bounds.contains(marker.position)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
animator.animateMarkerDisappear(marker, tint, highlight, fault, multi, fav)
val fav =
charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
animator.animateMarkerDisappear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
} else {
animator.deleteMarker(marker)
}
@@ -1054,13 +1117,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
for (charger in chargers) {
if (!map1.contains(charger.id)) {
val tint = getMarkerTint(charger, vm.filteredConnectors.value)
val highlight = charger == vm.chargerSparse.value
val highlight = charger.id == vm.chargerSparse.value?.id
val fault = charger.faultReport != null
val multi = charger.isMulti(vm.filteredConnectors.value)
val fav = charger.id in vm.favorites.value?.map { it.id } ?: emptyList()
val fav = charger.id in vm.favorites.value?.map { it.charger.id } ?: emptyList()
val marker = map.addMarker(
MarkerOptions()
.position(LatLng(charger.coordinates.lat, charger.coordinates.lng))
.z(chargerZ)
.icon(
chargerIconGenerator.getBitmapDescriptor(
tint,
@@ -1069,12 +1133,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
highlight,
fault,
multi,
fav
fav,
vm.useMiniMarkers.value == true
)
)
.anchor(0.5f, 1f)
.anchor(0.5f, if (vm.useMiniMarkers.value == true) 0.5f else 1f)
)
animator.animateMarkerAppear(
marker, tint, highlight, fault, multi, fav,
vm.useMiniMarkers.value == true
)
animator.animateMarkerAppear(marker, tint, highlight, fault, multi, fav)
markers[marker] = charger
}
}
@@ -1084,6 +1152,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
map.addMarker(
MarkerOptions()
.position(LatLng(cluster.coordinates.lat, cluster.coordinates.lng))
.z(clusterZ)
.icon(
map.bitmapDescriptorFactory.fromBitmap(
clusterIconGenerator.makeIcon(
@@ -1096,23 +1165,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
@SuppressLint("MissingPermission")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if ((grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED })) {
enableLocation(moveTo = true, animate = true)
}
}
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.map, menu)
val filterItem = menu.findItem(R.id.menu_filter)
@@ -1227,6 +1280,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
})
})
popup.setTouchModal(false)
popup.show()
}
@@ -1250,42 +1304,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return false
}
override fun getRootView(): View {
return binding.root
}
override fun onConnected() {
val map = this.map ?: return
val context = this.context ?: return
if (vm.myLocationEnabled.value == true) {
if (context.checkAnyLocationPermission()) {
moveToLastLocation(map, false)
requestLocationUpdates()
}
}
}
@RequiresPermission(ACCESS_FINE_LOCATION)
@RequiresPermission(anyOf = [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION])
private fun requestLocationUpdates() {
val request: LocationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.setInterval(5000)
LocationServices.FusedLocationApi.requestLocationUpdates(locationClient, request, this)
locationEngine.requestLocationUpdates(
Priority.HIGH_ACCURACY,
5000,
locationListener
)
requestingLocationUpdates = true
}
@SuppressLint("MissingPermission")
private fun removeLocationUpdates() {
if (locationClient.isConnected) {
LocationServices.FusedLocationApi.removeLocationUpdates(locationClient, this)
if (context?.checkAnyLocationPermission() == true) {
locationEngine.removeUpdates(locationListener)
}
}
override fun onConnectionSuspended() {
}
override fun onLocationChanged(location: Location?) {
val map = this.map ?: return
if (location == null || vm.myLocationEnabled.value == false) return
private val locationListener = LocationListenerCompat { location ->
val map = this.map ?: return@LocationListenerCompat
if (vm.myLocationEnabled.value == false) return@LocationListenerCompat
val latLng = LatLng(location.latitude, location.longitude)
val oldLoc = vm.location.value
@@ -1310,8 +1357,5 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
override fun onDestroy() {
super.onDestroy()
if (locationClient.isConnected) {
locationClient.disconnect()
}
}
}

View File

@@ -4,20 +4,16 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.databinding.DialogMultiSelectBinding
import net.vonforst.evmap.ui.MaterialDialogFragment
import java.util.*
import kotlin.collections.HashMap
import kotlin.collections.HashSet
import kotlin.math.roundToInt
class MultiSelectDialog : AppCompatDialogFragment() {
class MultiSelectDialog : MaterialDialogFragment() {
companion object {
fun getInstance(
title: String,
@@ -43,7 +39,7 @@ class MultiSelectDialog : AppCompatDialogFragment() {
private lateinit var items: List<MultiSelectItem>
private lateinit var binding: DialogMultiSelectBinding
override fun onCreateView(
override fun createView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
@@ -54,19 +50,10 @@ class MultiSelectDialog : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
val density = resources.displayMetrics.density
val width = resources.displayMetrics.widthPixels
val maxWidth = (500 * density).roundToInt()
// dialog with 95% screen height
dialog?.window?.setLayout(
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
setFullSize(maxWidthDp = 500)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
override fun initView(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val data = args.getSerializable("data") as HashMap<String, String>
val selected = args.getSerializable("selected") as HashSet<String>

View File

@@ -8,6 +8,7 @@ import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialFadeThrough
import com.google.android.material.transition.MaterialSharedAxis
import com.mikepenz.aboutlibraries.LibsBuilder
@@ -42,8 +43,16 @@ class AboutFragment : PreferenceFragmentCompat() {
findPreference<Preference>("version")?.summary = BuildConfig.VERSION_NAME
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"contributors" -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.about_contributors)
.setMessage(getString(R.string.about_contributors_text) + "\n\n" + getString(R.string.about_contributors_list))
.setPositiveButton(R.string.ok) { _, _ -> }
.show()
true
}
"github_link" -> {
(activity as? MapsActivity)?.openUrl(getString(R.string.github_link))
true

View File

@@ -48,12 +48,11 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(this)
preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
}

View File

@@ -16,7 +16,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
@@ -69,7 +70,8 @@ class ChargepriceSettingsFragment : BaseSettingsFragment() {
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + getString(R.string.pref_my_tariffs_summary)
) + "\n" + requireContext().resources
.getQuantityString(R.plurals.pref_my_tariffs_summary, n)
}
}

View File

@@ -17,7 +17,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key)
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
@@ -47,8 +48,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
"search_delete_recent" -> {
Snackbar.make(
requireView(),

View File

@@ -1,28 +1,62 @@
package net.vonforst.evmap.fragment.preference
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.preference.ListPreference
import androidx.preference.Preference
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.getAppLocale
import net.vonforst.evmap.ui.updateAppLocale
import net.vonforst.evmap.ui.updateNightMode
class UiSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
lateinit var langPref: ListPreference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_ui, rootKey)
langPref = findPreference("language")!!
langPref.setOnPreferenceChangeListener { _, newValue ->
updateAppLocale(newValue as String)
true
}
val appLinkPref = findPreference<Preference>("applink_associate")!!
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
override fun onResume() {
super.onResume()
langPref.value = getAppLocale()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"language" -> {
activity?.let {
it.finish();
it.startActivity(it.intent);
}
}
"darkmode" -> {
updateNightMode(prefs)
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
"applink_associate" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val context = context ?: return false
val intent = Intent(
Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:${context.packageName}")
)
context.startActivity(intent)
}
return true
}
}
return super.onPreferenceTreeClick(preference)
}
}

View File

@@ -4,18 +4,16 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.navigation.fragment.findNavController
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.roundToInt
import net.vonforst.evmap.ui.MaterialDialogFragment
class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
private lateinit var binding: DialogOpensourceDonationsBinding
override fun onCreateView(
override fun createView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
@@ -24,9 +22,7 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun initView(view: View, savedInstanceState: Bundle?) {
val prefs = PreferenceDataSource(requireContext())
binding.btnOk.setOnClickListener {
prefs.opensourceDonationsDialogShown = true
@@ -44,14 +40,5 @@ class OpensourceDonationsDialogFramgent : AppCompatDialogFragment() {
override fun onStart() {
super.onStart()
val density = resources.displayMetrics.density
val width = resources.displayMetrics.widthPixels
val maxWidth = (500 * density).roundToInt()
dialog?.window?.setLayout(
if (width < maxWidth) WindowManager.LayoutParams.MATCH_PARENT else maxWidth,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}

View File

@@ -0,0 +1,271 @@
package net.vonforst.evmap.location
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.core.location.LocationListenerCompat
/**
* Location engine that fuses GPS and network locations.
*
* Simplified version of
* https://github.com/lostzen/lost/blob/master/lost/src/main/java/com/mapzen/android/lost/internal/FusionEngine.java
*/
class FusionEngine(context: Context) : LocationEngine(context),
LocationListenerCompat {
/**
* Location updates more than 60 seconds old are considered stale.
*/
private val RECENT_UPDATE_THRESHOLD_IN_MILLIS = (60 * 1000).toLong()
private val RECENT_UPDATE_THRESHOLD_IN_NANOS = RECENT_UPDATE_THRESHOLD_IN_MILLIS * 1000000
private val TAG = FusionEngine::class.java.simpleName
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var gpsLocation: Location? = null
private var networkLocation: Location? = null
private val supportsSystemFusedProvider: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.allProviders.contains(
LocationManager.FUSED_PROVIDER
)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun getLastKnownLocation(): Location? {
if (supportsSystemFusedProvider) {
try {
return locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER)
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
}
val minTime = SystemClock.elapsedRealtimeNanos() - RECENT_UPDATE_THRESHOLD_IN_NANOS
var bestLocation: Location? = null
var bestAccuracy = Float.MAX_VALUE
var bestTime = Long.MIN_VALUE
for (provider in locationManager.allProviders) {
try {
val location = locationManager.getLastKnownLocation(provider)
if (location != null) {
val accuracy = location.accuracy
val time = location.elapsedRealtimeNanos
if (time > minTime && accuracy < bestAccuracy) {
bestLocation = location
bestAccuracy = accuracy
bestTime = time
} else if (time < minTime && bestAccuracy == Float.MAX_VALUE && time > bestTime) {
bestLocation = location
bestTime = time
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for provider: $provider", e)
}
}
return bestLocation
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun enable() {
var networkInterval = Long.MAX_VALUE
var gpsInterval = Long.MAX_VALUE
var passiveInterval = Long.MAX_VALUE
for ((priority, interval) in requests) {
when (priority) {
Priority.HIGH_ACCURACY -> {
if (interval < gpsInterval) {
gpsInterval = interval
}
if (interval < networkInterval) {
networkInterval = interval
}
}
Priority.BALANCED_POWER_ACCURACY, Priority.LOW_POWER -> if (interval < networkInterval) {
networkInterval = interval
}
Priority.NO_POWER -> if (interval < passiveInterval) {
passiveInterval = interval
}
}
}
if (supportsSystemFusedProvider && gpsInterval < Long.MAX_VALUE) {
try {
enableFused(gpsInterval)
checkLastKnownFused()
return
} catch (e: SecurityException) {
Log.e(TAG, "Permissions not granted for fused provider", e)
}
}
var checkGps = false
if (gpsInterval < Long.MAX_VALUE) {
enableGps(gpsInterval)
checkGps = true
}
if (networkInterval < Long.MAX_VALUE) {
enableNetwork(networkInterval)
if (checkGps) {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork =
locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
if (lastGps != null && lastNetwork != null) {
val useGps = lastGps.isBetterThan(lastNetwork)
if (useGps) {
checkLastKnownGps()
} else {
checkLastKnownNetwork()
}
} else if (lastGps != null) {
checkLastKnownGps()
} else {
checkLastKnownNetwork()
}
} else {
checkLastKnownNetwork()
}
}
if (passiveInterval < Long.MAX_VALUE) {
enablePassive(passiveInterval)
checkLastKnownPassive()
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
override fun disable() {
locationManager.removeUpdates(this)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableGps(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for GPS updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableNetwork(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for network updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enablePassive(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.PASSIVE_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun enableFused(interval: Long) {
try {
locationManager.requestLocationUpdates(
LocationManager.FUSED_PROVIDER,
interval,
0f,
this,
looper
)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Unable to register for passive updates.", e)
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownGps() {
checkLastKnownAndNotify(LocationManager.GPS_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownNetwork() {
checkLastKnownAndNotify(LocationManager.NETWORK_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownPassive() {
checkLastKnownAndNotify(LocationManager.PASSIVE_PROVIDER)
}
@RequiresApi(Build.VERSION_CODES.S)
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownFused() {
checkLastKnownAndNotify(LocationManager.FUSED_PROVIDER)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
private fun checkLastKnownAndNotify(provider: String) {
val location = locationManager.getLastKnownLocation(provider)
location?.let { onLocationChanged(it) }
}
override fun onLocationChanged(location: Location) {
if (LocationManager.FUSED_PROVIDER == location.provider) {
requests.forEach { it.listener.onLocationChanged(location) }
} else if (LocationManager.GPS_PROVIDER == location.provider) {
gpsLocation = location
if (gpsLocation.isBetterThan(networkLocation)) {
requests.forEach { it.listener.onLocationChanged(location) }
}
} else if (LocationManager.NETWORK_PROVIDER == location.provider) {
networkLocation = location
if (networkLocation.isBetterThan(gpsLocation)) {
requests.forEach { it.listener.onLocationChanged(location) }
}
}
}
private fun Location?.isBetterThan(other: Location?): Boolean {
if (this == null) {
return false
}
if (other == null) {
return true
}
if (this.elapsedRealtimeNanos
> other.elapsedRealtimeNanos + RECENT_UPDATE_THRESHOLD_IN_NANOS
) {
return true
}
if (!this.hasAccuracy()) {
return false
}
return if (!other.hasAccuracy()) {
true
} else this.accuracy < other.accuracy
}
}

View File

@@ -0,0 +1,85 @@
package net.vonforst.evmap.location
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.os.Looper
import androidx.annotation.RequiresPermission
/**
* Base class for [com.mapzen.android.lost.internal.FusionEngine].
*/
abstract class LocationEngine(protected val context: Context) {
protected val requests: MutableList<LocationRequest> = mutableListOf()
/**
* Return most best recent location available.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
abstract fun getLastKnownLocation(): Location?
/**
* Enables the engine on receiving a valid location request.
*
* @param request Valid location request to enable.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun requestLocationUpdates(priority: Priority, intervalMs: Long, listener: LocationListener) {
if (!requests.any { it.listener == listener }) {
requests.add(LocationRequest(priority, intervalMs, listener))
}
enable()
}
/**
* Disables the engine when no requests remain, otherwise updates the engine's configuration.
*
* @param requests Valid location request to enable.
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun removeUpdates(listener: LocationListener) {
this.requests.removeIf { it.listener == listener }
disable()
if (this.requests.isNotEmpty()) enable()
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
fun removeAllRequests() {
requests.clear()
disable()
}
/**
* Subclass should perform all operations required to enable the engine. (ex. Register for
* location updates.)
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
protected abstract fun enable()
/**
* Subclass should perform all operations required to disable the engine. (ex. Remove location
* updates.)
*/
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION])
protected abstract fun disable()
protected val looper: Looper
get() = context.mainLooper
interface Callback {
fun reportLocation(location: Location)
}
}
data class LocationRequest(
val priority: Priority,
val intervalMs: Long,
val listener: LocationListener
)
enum class Priority {
HIGH_ACCURACY,
BALANCED_POWER_ACCURACY,
LOW_POWER,
NO_POWER
}

View File

@@ -24,6 +24,36 @@ import kotlin.math.floor
sealed class ChargepointListItem
/**
* A whole charging site (potentially with multiple chargepoints).
*
* @param id A unique number per charging site
* @param dataSource The name of the data source
* @param coordinates The latitude / longitude of this charge location
* @param address The charge location address
* @param chargepoints List of chargepoints at this location
* @param network The charging network (Mobility Service Provider, MSP)
* @param url A link to this charging site
* @param editUrl A link to a website where this charging site can be edited
* @param faultReport Set this if the charging site is reported to be out of service
* @param verified For crowdsourced data sources, this means that the data has been verified
* by an independent person
* @param barrierFree Whether this charge location can be used without prior registration
* @param operator The operator of this charge location (Charge Point Operator, CPO)
* @param generalInformation General information about this charging site that does not fit anywhere else
* @param amenities Description of amenities available at or near the charging site (toilets, food, accommodation, landmarks, etc.)
* @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5")
* @param photos List of photos of this charging site
* @param chargecards List of charge cards accepted here
* @param openinghours List of times when this charging site can be accessed / used
* @param cost The cost for charging and/or parking
* @param license How the data about this chargepoint is licensed
* @param chargepriceData Additional data needed for the Chargeprice implementation
* @param timeRetrieved Time when this information was retrieved from the data source
* @param isDetailed Whether this data includes all available details (for many data sources,
* API calls that return a list may only give a compact representation)
*/
@Entity(primaryKeys = ["id", "dataSource"])
@Parcelize
data class ChargeLocation(
@@ -31,7 +61,7 @@ data class ChargeLocation(
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
@@ -49,12 +79,14 @@ data class ChargeLocation(
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?,
val license: String?,
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?,
val timeRetrieved: Instant,
val isDetailed: Boolean
) : ChargepointListItem(), Equatable, Parcelable {
/**
* maximum power available from this charger.
*/
val maxPower: Double
val maxPower: Double?
get() {
return maxPower()
}
@@ -62,17 +94,20 @@ data class ChargeLocation(
/**
* Gets the maximum power available from certain connectors of this charger.
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.maxOrNull() ?: 0.0
fun maxPower(connectors: Set<String>? = null): Double? {
return chargepoints
.filter { connectors?.contains(it.type) ?: true }
.mapNotNull { it.power }
.maxOrNull()
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
val chargepointMaxPower = maxPower(filteredConnectors)
if (chargepointMaxPower != null && chargepointMaxPower >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
chargepoints = chargepoints.filter {it.power != null && it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
@@ -132,9 +167,9 @@ data class Cost(
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
if (freecharging != null && freeparking != null) {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
if (freecharging) ctx.getString(R.string.charging_free) else ctx.getString(R.string.charging_paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
if (freeparking) ctx.getString(R.string.parking_free) else ctx.getString(R.string.parking_paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
@@ -142,7 +177,7 @@ data class Cost(
}
} else if (freecharging != null) {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
if (freecharging) ctx.getString(R.string.charging_free) else ctx.getString(R.string.charging_paid)
return if (emoji) {
"$charging"
} else {
@@ -150,7 +185,7 @@ data class Cost(
}
} else if (freeparking != null) {
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
if (freeparking) ctx.getString(R.string.parking_free) else ctx.getString(R.string.parking_paid)
return if (emoji) {
"\uD83C\uDD7F $parking"
} else {
@@ -164,6 +199,18 @@ data class Cost(
return ""
}
}
fun getDetailText(): CharSequence? {
return if (freecharging == null && freeparking == null) {
if (descriptionShort != null && descriptionLong != descriptionShort) {
descriptionLong
} else {
null
}
} else {
descriptionLong ?: descriptionShort
}
}
}
@Parcelize
@@ -180,26 +227,63 @@ data class OpeningHours(
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
} else if (days != null) {
val hours = days.getHoursForDate(LocalDate.now())
?: return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
val today = LocalDate.now()
val hours = days.getHoursForDate(today)
val nextDayHours = days.getHoursForDate(today.plusDays(1))
val previousDayHours = days.getHoursForDate(today.minusDays(1))
val now = LocalTime.now()
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
if (previousDayHours != null && previousDayHours.end.isBefore(previousDayHours.start) && previousDayHours.end.isAfter(
now
)
) {
// previous day has opening hours that go past midnight
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.toString()
previousDayHours.end.format(fmt)
), 0
)
} else if (hours.end.isBefore(now)) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
} else {
} else if (hours != null && hours.start.isBefore(hours.end)
&& hours.start.isBefore(now) && hours.end.isAfter(now)
) {
// current day has opening hours that do not go past midnight
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.format(fmt)
), 0
)
} else if (hours != null && hours.end.isBefore(hours.start)
&& hours.start.isBefore(now)
) {
// current day has opening hours that go past midnight
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.format(fmt)
), 0
)
} else if (hours != null && !hours.start.isBefore(now)) {
// currently closed, will still open on this day
return HtmlCompat.fromHtml(
ctx.getString(
R.string.closed_opensat,
hours.start.toString()
hours.start.format(fmt)
), 0
)
} else if (nextDayHours != null) {
// currently closed, will open next day
return HtmlCompat.fromHtml(
ctx.getString(
R.string.closed_opensat,
nextDayHours.start.format(fmt)
), 0
)
} else {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
}
} else {
return ""
@@ -290,15 +374,47 @@ data class Address(
val street: String?
) : Parcelable {
override fun toString(): String {
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
// TODO: the order here follows a German-style format (i.e. street, postcode city).
// in principle this should be country-dependent (e.g. UK has postcode after city)
return buildString {
street?.let {
append(it)
append(", ")
}
postcode?.let {
append(it)
append(" ")
}
city?.let {
append(it)
}
}
}
}
/**
* One socket with a certain power, which may be available multiple times at a ChargeLocation.
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable,
Parcelable {
fun formatPower(): String {
data class Chargepoint(
// The chargepoint type (use one of the constants in the companion object)
val type: String,
// Power in kW (or null if unknown)
val power: Double?,
// How many instances of this plug/socket are available?
val count: Int,
) : Equatable, Parcelable {
fun hasKnownPower(): Boolean = power != null
/**
* If chargepoint power is defined, format it into a string.
* Otherwise, return null.
*/
fun formatPower(): String? {
if (power == null) {
return null
}
val powerFmt = if (power - power.toInt() == 0.0) {
"%.0f".format(power)
} else {

View File

@@ -0,0 +1,28 @@
package net.vonforst.evmap.model
import androidx.room.*
@Entity(
foreignKeys = [
ForeignKey(
entity = ChargeLocation::class,
parentColumns = arrayOf("id", "dataSource"),
childColumns = arrayOf("chargerId", "chargerDataSource"),
onDelete = ForeignKey.NO_ACTION,
)
],
indices = [
Index(value = ["chargerId", "chargerDataSource"])
]
)
data class Favorite(
@PrimaryKey(autoGenerate = true)
val favoriteId: Long = 0,
val chargerId: Long,
val chargerDataSource: String
)
data class FavoriteWithDetail(
@Embedded() val favorite: Favorite,
@Embedded val charger: ChargeLocation
)

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.model
import androidx.databinding.BaseObservable
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.storage.FilterProfile
import kotlin.reflect.KClass
@@ -59,7 +60,10 @@ sealed class FilterValue : BaseObservable(), Equatable {
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
primaryKeys = ["key", "profile", "dataSource"],
indices = [
Index(value = ["profile", "dataSource"])
]
)
data class BooleanFilterValue(
override val key: String,
@@ -77,7 +81,10 @@ data class BooleanFilterValue(
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
primaryKeys = ["key", "profile", "dataSource"],
indices = [
Index(value = ["profile", "dataSource"])
]
)
data class MultipleChoiceFilterValue(
override val key: String,
@@ -101,7 +108,10 @@ data class MultipleChoiceFilterValue(
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
primaryKeys = ["key", "profile", "dataSource"],
indices = [
Index(value = ["profile", "dataSource"])
]
)
data class SliderFilterValue(
override val key: String,

View File

@@ -1,12 +1,12 @@
package net.vonforst.evmap.navigation
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.fragment.NavHostFragment
class NavHostFragment : NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider.addNavigator(
override fun onCreateNavHostController(navHostController: NavHostController) {
super.onCreateNavHostController(navHostController)
navHostController.navigatorProvider.addNavigator(
CustomNavigator(
requireContext()
)

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