Compare commits

..

109 Commits

Author SHA1 Message Date
johan12345
c0b4c56eda Release 1.4.5 2023-01-20 20:10:57 +01:00
Hosted Weblate
9587ee948d Update translation files
Updated by "Squash Git commits" hook in Weblate.

Translated using Weblate (Norwegian Bokmål)

Currently translated at 83.3% (30 of 36 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/
Translate-URL: https://hosted.weblate.org/projects/evmap/android-google/nb_NO/
Translation: EVMap/Android (strings specific to Google Play variant)
2023-01-20 19:13:16 +01:00
johan12345
890eec4419 FilterFragment: add button to reset current settings 2023-01-20 19:12:52 +01:00
johan12345
c972c871d4 add icons to filter popup menu 2023-01-20 18:38:06 +01:00
johan12345
e4da902430 GooglePlacesAutocompleteProvider: fix crash on network error 2023-01-19 22:28:13 +01:00
johan12345
7a5d4b4107 fix NPE 2023-01-08 12:48:23 +01:00
johan12345
80642b1731 fix infinite recursion in Utils.max 2023-01-08 12:45:31 +01:00
johan12345
6dab611c1b Release 1.4.4 2022-12-15 21:40:22 +01:00
johan12345
d9fc43af68 fix size of layers FAB
(fabSize=mini did not apply anymore since #135)
2022-12-11 19:01:24 +01:00
johan12345
2fd0fa7e22 update dependencies 2022-12-11 18:51:56 +01:00
johan12345
b04284fb16 AA/AAOS: fix crash when icon for plug type is not available 2022-12-11 17:58:06 +01:00
johan12345
7b3bd84d18 AA/AAOS: clear list of chargers on loading error 2022-12-11 17:49:04 +01:00
johan12345
773d052819 fix NPE in OpenChargeMapApi 2022-12-11 12:29:46 +01:00
johan12345
4e0ad98e17 AA/AAOS: implement app-driven refresh
(if supported by host)
2022-12-11 00:34:05 +01:00
johan12345
d8e572338a upgrade car app library to 1.3.0-rc01 2022-12-10 23:59:51 +01:00
johan12345
ff86eeff95 AA/AAOS: add some more useful info to developer options screen 2022-12-10 23:58:26 +01:00
johan12345
47f57992fb add extension function to abbreviate getContentLimit calls 2022-12-10 23:31:40 +01:00
johan12345
0ae59358ca AA/AAOS: implement a full "about" screen 2022-12-10 23:21:29 +01:00
johan12345
576e0b9c42 add @ExperimentalCarApi 2022-12-10 22:52:26 +01:00
johan12345
3878b27154 Revert "AAOS: CarSensorsWrapper: add experimental rotation sensor implementations"
This reverts commit e2cf332f34.
2022-12-10 22:46:31 +01:00
johan12345
2166ac076a Android Auto/Automotive: Fall back to GPS bearing if compass not available 2022-12-10 22:45:50 +01:00
johan12345
c489df2aaf Android Auto/Automotive: Fix crash when no or all prices match "my plans" 2022-12-09 21:43:43 +01:00
johan12345
56712ff1af Android Auto/Automotive: Fix crash when no prices are found 2022-12-09 21:34:45 +01:00
johan12345
e2cf332f34 AAOS: CarSensorsWrapper: add experimental rotation sensor implementations 2022-12-08 22:53:03 +01:00
johan12345
0b541d498d AA/AAOS: add developer options screen 2022-12-08 22:52:27 +01:00
johan12345
1bdc576300 AA/AAOS: enable search button while driving (#262)
Note that AA/AAOS will block access to keyboard while driving, but the search screen is still useful to access recent results. Also this enables the "clear search" button while driving.
2022-12-08 21:49:09 +01:00
johan12345
fb5da76834 fix changelogs 2022-11-30 19:59:48 +01:00
johan12345
ad922f0667 Release 1.4.3 2022-11-30 19:46:20 +01:00
johan12345
773b35d9ce Android Auto Place search: fix clickability when distance is not available 2022-11-30 19:26:34 +01:00
johan12345
a3347c9d62 ChargepriceScreen: use sectioned list instead of disabled state to separate own plans from others 2022-11-30 19:18:46 +01:00
johan12345
da671b8dd3 German string: fix informal form 2022-11-30 18:54:59 +01:00
johan12345
6d877e13e4 re-enable refresh button on AAOS
this is a workaround for https://issuetracker.google.com/issues/260112181
2022-11-30 18:45:23 +01:00
johan12345
8ab1d7170c update CustomBottomSheetBehavior
fixes #260
2022-11-26 21:15:44 +01:00
johan12345
1f75d722cd Implement multi-EVSEID request for fronyx API 2022-11-21 08:49:37 +01:00
johan12345
11bd4b2cec fix NPE in ChargepriceFragment 2022-11-20 20:30:23 +01:00
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
100 changed files with 3400 additions and 638 deletions

View File

@@ -4,4 +4,5 @@
<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>

View File

@@ -31,6 +31,7 @@ jobs:
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 }}

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

@@ -19,10 +19,10 @@ android {
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 31
targetSdkVersion 33
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 124
versionName "1.3.13"
versionCode 158
versionName "1.4.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs supportedLocales.split(",")
@@ -141,6 +141,13 @@ 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
}
}
}
@@ -153,28 +160,25 @@ configurations {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.0-rc01'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.activity:activity-ktx:1.5.1"
implementation "androidx.fragment:fragment-ktx:1.5.2"
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.6.1'
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 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:dd0167dbff'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f4f641aab5'
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 'com.github.johan12345:jsonapi:50d72e7e55' // patched version for jsonapi-adapters
implementation('com.markomilos.jsonapi:jsonapi-retrofit:1.0.1') {
exclude group: 'com.markomilos.jsonapi', module: 'jsonapi-adapters'
}
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"
@@ -186,7 +190,7 @@ dependencies {
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
def carAppVersion = '1.3.0-beta01'
def carAppVersion = '1.3.0-rc01'
googleImplementation "androidx.car.app:app:$carAppVersion"
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
@@ -206,7 +210,7 @@ dependencies {
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
// Google Places
googleImplementation 'com.google.android.libraries.places:places:2.6.0'
googleImplementation 'com.google.android.libraries.places:places:2.7.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
// Mapbox Geocoding
@@ -228,7 +232,7 @@ dependencies {
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "4.1.0"
def billing_version = "5.1.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
@@ -250,11 +254,11 @@ dependencies {
// testing for car app
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
testGoogleImplementation 'org.robolectric:robolectric:4.8.1'
testGoogleImplementation 'androidx.test:core:1.4.0'
testGoogleImplementation 'org.robolectric:robolectric:4.9'
testGoogleImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.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"

View File

@@ -2,5 +2,4 @@
<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

@@ -11,6 +11,7 @@
<queries>
<package android:name="com.google.android.projection.gearhead" />
<package android:name="com.google.android.apps.automotive.templates.host" />
</queries>
<application>

View File

@@ -31,6 +31,7 @@ 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
@@ -111,27 +112,49 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val prefs: PreferenceDataSource by lazy {
PreferenceDataSource(carContext)
}
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
val mapScreen = MapScreen(carContext, this)
val screens = mutableListOf<Screen>(mapScreen)
if (!prefs.dataSourceSet) {
screens.add(
ChooseDataSourceScreen(
carContext,
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE,
initialChoice = true,
extraDesc = R.string.data_sources_description
)
)
}
if (!locationPermissionGranted()) {
val screenManager = carContext.getCarService(ScreenManager::class.java)
screenManager.push(mapScreen)
return PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
screens.add(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
)
}
return mapScreen
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()

View File

@@ -1,10 +1,5 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
@@ -24,11 +19,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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)
@@ -41,9 +42,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private var chargepoint: Chargepoint? = null
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
@@ -62,17 +62,62 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
setSingleList(ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.take(maxRows)?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
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
)
}"
}
}.build())
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val prices = prices?.take(maxRows)
if (prices != null && prices.isNotEmpty() && !myTariffsAll && myTariffs != null) {
val (myPrices, otherPrices) = prices.partition { price -> price.tariffId in myTariffs }
val myPricesList = buildPricesList(myPrices)
val otherPricesList = buildPricesList(otherPrices)
if (myPricesList.items.isNotEmpty() && otherPricesList.items.isNotEmpty()) {
addSectionedList(
SectionedItemList.create(
myPricesList,
(header?.let { it + "\n" } ?: "") +
carContext.getString(R.string.chargeprice_header_my_tariffs)
)
)
addSectionedList(
SectionedItemList.create(
otherPricesList,
carContext.getString(R.string.chargeprice_header_other_tariffs)
)
)
} else {
val list =
if (myPricesList.items.isNotEmpty()) myPricesList else otherPricesList
if (header != null) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
} else {
val list = buildPricesList(prices)
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}
setActionStrip(
ActionStrip.Builder().addAction(
@@ -84,41 +129,28 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
).build()
).setOnClickListener {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data =
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()
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
openUrl(carContext, ChargepriceApi.getPoiUrl(charger))
}.build()
).build()
)
}.build()
}
private fun buildPricesList(prices: List<ChargePrice>?): ItemList {
return ItemList.Builder().apply {
setNoItemsMessage(
errorMessage
?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
@@ -128,19 +160,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
@@ -179,6 +213,14 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
if (cpStation.chargePoints.isEmpty()) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
@@ -189,7 +231,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
@@ -213,10 +256,14 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
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)
@@ -226,11 +273,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.map { cp ->
val filteredPrices =
@@ -245,7 +288,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
)
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs

View File

@@ -1,11 +1,7 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.*
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableString
@@ -68,9 +64,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private val iconGen =
ChargerIconGenerator(carContext, null, height = imageSize)
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
} else 2
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
private val largeImageSupported =
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
@@ -104,29 +98,29 @@ 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 {
@@ -354,23 +348,31 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${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())
chargepointsText.apply {
if (i > 0) append(" · ")
append("${cp.count}× ")
val plugIcon = iconForPlugType(cp.type)
if (plugIcon != 0) {
append(
nameForPlugType(carContext.stringProvider(), cp.type),
CarIconSpan.create(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
plugIcon
)
).setTint(
CarColor.createCustom(Color.WHITE, Color.BLACK)
).build()
),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
} else {
append(nameForPlugType(carContext.stringProvider(), cp.type))
}
append(" ")
append(cp.formatPower())
}
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
@@ -416,6 +418,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
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 {
@@ -458,7 +462,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
)
this@ChargerDetailScreen.photo = outImg
}
this@ChargerDetailScreen.charger = charger
invalidate()
availability = getAvailability(charger).data

View File

@@ -1,6 +1,8 @@
package net.vonforst.evmap.auto
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
@@ -11,6 +13,7 @@ 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.*
@@ -24,15 +27,22 @@ import kotlin.math.roundToInt
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 = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
} else 6
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
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()
}
}
@@ -40,10 +50,24 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
val filterStatus = prefs.filterStatus
return ListTemplate.Builder().apply {
var title = carContext.getString(R.string.menu_filter)
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it, 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 {
@@ -55,7 +79,6 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
R.drawable.ic_edit
)
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
lifecycleScope.launch {
@@ -70,47 +93,148 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
}.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(
profiles: List<FilterProfile>,
paginatedProfiles: List<List<FilterProfile>>,
filterStatus: Long
): ItemList {
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
val profilesToShow =
profiles.sortedByDescending { it.id == filterStatus }.take(maxRows - extraRows)
return ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_favorites))
}.build())
profilesToShow.forEach {
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
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
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 (FILTERS_CUSTOM == filterStatus) {
if (page < paginatedProfiles.size - 1) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.filter_custom))
}.build())
}
setSelectedIndex(when (filterStatus) {
FILTERS_DISABLED -> 0
FILTERS_FAVORITES -> 1
FILTERS_CUSTOM -> profilesToShow.size + 2
else -> profilesToShow.indexOfFirst { it.id == filterStatus } + 2
})
setOnSelectedListener { index ->
onItemClick(
when (index) {
0 -> FILTERS_DISABLED
1 -> FILTERS_FAVORITES
profilesToShow.size + 2 -> FILTERS_CUSTOM
else -> profilesToShow[index - 2].id
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
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
)
}.build())
}
}.build()
}
@@ -125,12 +249,16 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
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 val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var page = 0
private var paginatedFilters = vm.filtersWithValue.map {
it?.paginate(maxRows, maxRows - 1, maxRows - 2, maxRows - 1)
}
init {
vm.filtersWithValue.observe(this) {
paginatedFilters.observe(this) {
vm.filterProfile.observe(this) {
invalidate()
}
@@ -141,18 +269,28 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
val currentProfileName = vm.filterProfile.value?.name
return ListTemplate.Builder().apply {
vm.filtersWithValue.value?.let { filtersWithValue ->
setSingleList(buildFiltersList(filtersWithValue.take(maxRows)))
paginatedFilters.value?.let { paginatedFilters ->
setSingleList(buildFiltersList(paginatedFilters))
} ?: setLoading(true)
setTitle(currentProfileName?.let {
var title = currentProfileName?.let {
carContext.getString(
R.string.edit_filter_profile,
it
it,
)
} ?: carContext.getString(R.string.menu_filter))
} ?: 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) {
@@ -194,29 +332,65 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
).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
lifecycleScope.launch {
vm.saveAsProfile(name as String)
screenManager.popTo(MapScreen.MARKER)
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())
}
.build())
}.build()
}
private fun buildFiltersList(filters: List<FilterWithValue<out FilterValue>>): ItemList {
private fun buildFiltersList(paginatedFilters: List<FilterValues>): ItemList {
return ItemList.Builder().apply {
filters.forEach {
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
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
paginatedFilters[page].forEach {
val filter = it.filter
val value = it.value
addItem(Row.Builder().apply {
@@ -270,6 +444,37 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
}
}.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
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
Handler(Looper.getMainLooper()).post {
invalidate()
}
}
} else {
invalidate()
}
}
}.build())
}
}.build()
}
}

View File

@@ -2,12 +2,17 @@ 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.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
@@ -32,7 +37,9 @@ 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
@@ -41,6 +48,7 @@ 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
@@ -63,8 +71,10 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private var location: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var lastChargersUpdateTime: 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 repo =
@@ -72,22 +82,23 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
private val searchRadius = 5 // kilometers
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private val chargersUpdateThresholdDistance = 500 // meters
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
HashMap()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
min(
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST),
25
)
} else 6
private val maxRows =
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
private var filterStatus = prefs.filterStatus
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
private val hardwareMan: CarHardwareManager by lazy {
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",
@@ -132,11 +143,11 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
}
}.build())
} ?: setLoading(true)
}
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it.latitude, it.longitude)).build())
} ?: setLoading(true)
}
}
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
@@ -163,6 +174,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
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)
}
@@ -188,7 +205,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
).setTint(CarColor.DEFAULT).build()
)
.setOnClickListener {
screenManager.push(SettingsScreen(carContext))
screenManager.push(SettingsScreen(carContext, session))
session.mapScreen = null
}
.build())
@@ -206,11 +223,16 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
).build()
)
setOnClickListener(ParkedOnlyOnClickListener.create {
setOnClickListener {
if (prefs.placeSearchResultAndroidAuto != null) {
prefs.placeSearchResultAndroidAutoName = null
prefs.placeSearchResultAndroidAuto = null
screenManager.pushForResult(DummyReturnScreen(carContext)) {
if (!supportsRefresh) {
screenManager.pushForResult(DummyReturnScreen(carContext)) {
chargers = null
loadChargers()
}
} else {
chargers = null
loadChargers()
}
@@ -226,7 +248,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
session.mapScreen = null
}
})
}
}.build())
.addAction(
Action.Builder()
@@ -246,7 +268,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
.build())
.build())
if (carContext.carAppApiLevel >= 5) {
if (carContext.carAppApiLevel >= 5 ||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
) {
setOnContentRefreshListener(this@MapScreen)
}
}.build()
@@ -345,6 +369,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
this.location = location
if (previousLocation == null) {
loadChargers()
return
}
val now = Instant.now()
@@ -355,6 +380,23 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// update displayed distances
invalidate()
}
// if chargers are searched around current location, consider app-driven refresh
val searchLocation =
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
val distance = searchLocation?.let {
distanceBetween(
it.latitude, it.longitude, location.latitude, location.longitude
)
} ?: 0.0
if (supportsRefresh && (lastChargersUpdateTime == null ||
Duration.between(
lastChargersUpdateTime,
now
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
) {
onContentRefreshRequested()
}
}
private fun loadChargers() {
@@ -383,39 +425,37 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
)
}
} else {
val response = repo.getChargepointsRadius(
searchLocation,
searchRadius,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
loadingError = true
return@launch
}
var chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < maxRows) {
// try again with larger radius
val response = repo.getChargepointsRadius(
searchLocation,
searchRadius * 10,
zoom = 16f,
filtersWithValue
).awaitFinished()
if (response.status == Status.ERROR) {
loadingError = true
invalidate()
return@launch
}
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
// 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
this@MapScreen.chargers = null
invalidate()
return@launch
}
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
if (prefs.placeSearchResultAndroidAutoName == null) {
chargers = headingFilter(
chargers,
searchLocation
)
}
if (chargers == null || chargers.size >= maxRows) {
break
}
}
this@MapScreen.chargers = chargers
}
updateCoroutine = null
lastChargersUpdateTime = Instant.now()
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
@@ -425,15 +465,53 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
}
/**
* Filters by heading if heading available and enabled
*/
private fun headingFilter(
chargers: List<ChargeLocation>?,
searchLocation: LatLng
): List<ChargeLocation>? {
// use compass heading if available, otherwise fall back to GPS
val location = location
val heading = heading?.orientations?.value?.get(0)
?: if (location?.hasBearing() == true) location.bearing else null
return heading?.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
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.
@@ -445,6 +523,14 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
}
private fun setupListeners() {
val exec = ContextCompat.getMainExecutor(carContext)
if (supportsCarApiLevel3(carContext)) {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCompassUpdated
)
}
if (!permissions.all {
ContextCompat.checkSelfPermission(
carContext,
@@ -455,8 +541,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
if (supportsCarApiLevel3(carContext)) {
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
}
@@ -467,13 +552,15 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
// (i.e. onGetTemplate is not called while the old data is still there)
chargers = null
availabilities.clear()
location = null
removeListeners()
}
private fun removeListeners() {
if (supportsCarApiLevel3(carContext)) {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
carSensors.removeCompassListener(::onCompassUpdated)
}
}

View File

@@ -105,15 +105,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
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)
}
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
screenManager.popTo(MapScreen.MARKER)
}
}
}.build())
@@ -148,6 +148,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
}
private suspend fun loadNewList(query: String) {
val location = location?.let { LatLng.fromLocation(it) }
for (provider in providers) {
try {
recentResults.clear()
@@ -161,7 +162,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
}
recentResults.addAll(recentPlaces)
resultList =
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
recentPlaces.map { it.asAutocompletePlace(location) }
invalidate()
// if we already have enough results or the query is short, stop here
@@ -170,7 +171,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
// then search online
val recentIds = recentPlaces.map { it.id }
resultList = withContext(Dispatchers.IO) {
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
(resultList!! + provider.autocomplete(query, location)
.filter { !recentIds.contains(it.id) }).take(maxItems)
}
invalidate()

View File

@@ -15,9 +15,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
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
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
protected abstract val isMultiSelect: Boolean
protected abstract val shouldShowSelectAll: Boolean

View File

@@ -1,14 +1,23 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.annotation.StringRes
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.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
@@ -18,7 +27,10 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.max
import kotlin.math.min
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.auto_settings))
@@ -69,11 +81,44 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(VehicleDataScreen(carContext))
screenManager.push(VehicleDataScreen(carContext, session))
}
.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_ahead))
.setToggle(Toggle.Builder {
prefs.showChargersAheadAndroidAuto = it
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).setTint(CarColor.DEFAULT).build()
)
.build()
)
}
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.about))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_about
)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(AboutScreen(carContext))
}
.build()
)
}.build())
}.build()
}
@@ -82,12 +127,9 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
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 dataSourceDescriptions = listOf(
carContext.getString(R.string.data_source_goingelectric_desc),
carContext.getString(R.string.data_source_openchargemap_desc)
)
val searchProviderNames =
carContext.resources.getStringArray(R.array.pref_search_provider_names)
val searchProviderValues =
@@ -108,14 +150,9 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
screenManager.push(
ChooseDataSourceScreen(
carContext,
R.string.pref_data_source,
dataSourceNames,
dataSourceValues,
prefs.dataSource,
dataSourceDescriptions
) {
prefs.dataSource = it
})
ChooseDataSourceScreen.Type.CHARGER_DATA_SOURCE
)
)
}
}.build())
addItem(Row.Builder().apply {
@@ -129,13 +166,9 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
screenManager.push(
ChooseDataSourceScreen(
carContext,
R.string.pref_search_provider,
searchProviderNames,
searchProviderValues,
prefs.searchProvider
) {
prefs.searchProvider = it
})
ChooseDataSourceScreen.Type.SEARCH_PROVIDER
)
)
}
}.build())
addItem(Row.Builder().apply {
@@ -158,41 +191,90 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
class ChooseDataSourceScreen(
ctx: CarContext,
@StringRes val title: Int,
val names: Array<String>,
val values: Array<String>,
val currentValue: String,
val descriptions: List<String>? = null,
val callback: (String) -> Unit
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(Action.BACK)
setSingleList(ItemList.Builder().apply {
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())
}
setOnSelectedListener {
callback(values[it])
screenManager.pop()
if (!initialChoice) {
setOnSelectedListener {
itemSelected(it)
}
setSelectedIndex(values.indexOf(currentValue))
}
setSelectedIndex(values.indexOf(currentValue))
}.build())
}.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
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
@@ -507,4 +589,165 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
)
}.build()
}
}
class AboutScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
var developerOptionsCounter = 0
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.about))
setHeaderAction(Action.BACK)
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.version))
.addText(BuildConfig.VERSION_NAME)
.addText(
carContext.getString(R.string.copyright) + " " + carContext.getString(
R.string.copyright_summary
)
)
.setBrowsable(prefs.developerModeEnabled)
.setOnClickListener {
if (!prefs.developerModeEnabled) {
developerOptionsCounter += 1
if (developerOptionsCounter >= 7) {
prefs.developerModeEnabled = true
invalidate()
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_enabled),
CarToast.LENGTH_SHORT
).show()
}
} else {
screenManager.pushForResult(DeveloperOptionsScreen(carContext)) {
developerOptionsCounter = 0
invalidate()
}
}
}.build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.faq))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.faq_link))
}).build()
)
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.donate))
.addText(carContext.getString(R.string.donate_desc))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
if (BuildConfig.FLAVOR_automotive == "automotive") {
// we can't open the donation page on the phone in this case
openUrl(carContext, carContext.getString(R.string.paypal_link))
} else {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_DONATE, true)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
}
}).build()
)
}.build(), carContext.getString(R.string.about)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.twitter))
.addText(carContext.getString(R.string.twitter_handle))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.twitter_url))
}).build()
)
if (maxRows > 6) {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.goingelectric_forum))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(
carContext,
carContext.getString(R.string.goingelectric_forum_url)
)
}).build()
)
}
}.build(), carContext.getString(R.string.contact)))
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.github_link_title))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.github_link))
}).build()
)
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.privacy))
.setBrowsable(true)
.setOnClickListener(ParkedOnlyOnClickListener.create {
openUrl(carContext, carContext.getString(R.string.privacy_link))
}).build()
)
}.build(), carContext.getString(R.string.other)))
}.build()
}
}
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.developer_options))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(
Row.Builder().apply {
setTitle("Car app API Level: ${carContext.carAppApiLevel}")
val hostPackage = carContext.hostInfo?.packageName
val hostVersion = hostPackage?.let {
try {
carContext.packageManager.getPackageInfo(it, 0).versionName
} catch (e: NameNotFoundException) {
null
}
}
addText("$hostPackage $hostVersion")
if (BuildConfig.FLAVOR_automotive == "automotive") {
addText(
"Sensor list: ${
(carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager).getSensorList(
Sensor.TYPE_ALL
).map { it.type }.joinToString(",")
}"
)
}
}.build()
)
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.disable_developer_mode))
setOnClickListener {
prefs.developerModeEnabled = false
CarToast.makeText(
carContext,
carContext.getString(R.string.developer_mode_disabled),
CarToast.LENGTH_SHORT
).show()
screenManager.pop()
}
}.build())
}.build())
}.build()
}
}

View File

@@ -1,14 +1,22 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.*
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
@@ -33,13 +41,32 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun CarContext.getContentLimit(id: Int) = if (carAppApiLevel >= 2) {
constraintManager.getContentLimit(id)
} else {
when (id) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST -> 6
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
else -> throw IllegalArgumentException("unknown limit ID")
}
}
val CarContext.isAppDrivenRefreshSupported
@androidx.car.app.annotations.ExperimentalCarApi
get() = if (carAppApiLevel >= 6) constraintManager.isAppDrivenRefreshEnabled else false
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
val emptyCarIcon = Bitmap.createBitmap(
1,
1,
Bitmap.Config.ARGB_8888
).asCarIcon()
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
@@ -134,6 +161,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(".")
@@ -154,6 +215,40 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
return true
}
fun openUrl(carContext: CarContext, url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data = Uri.parse(url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
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,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
}
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
/*
Dummy screen to get around template refresh limitations.

View File

@@ -1,6 +1,7 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
@@ -16,10 +17,13 @@ import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.CompassNeedle
import net.vonforst.evmap.ui.Gauge
import net.vonforst.evmap.utils.formatDecimal
import kotlin.math.min
import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
@androidx.car.app.annotations.ExperimentalCarApi
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
LocationAwareScreen, DefaultLifecycleObserver {
private val carInfo =
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
private val carSensors = carContext.patchedCarSensors
@@ -27,6 +31,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var heading: Compass? = null
private var location: Location? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private var compass =
CompassNeedle((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
@@ -70,7 +75,11 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
val energyLevel = energyLevel
val model = model
val speed = speed
val heading = heading
val location = location
val compassHeading = heading?.orientations?.value?.get(0)
val gpsHeading = if (location?.hasBearing() == true) location.bearing else null
val heading = compassHeading ?: gpsHeading
return GridTemplate.Builder().apply {
setTitle(
@@ -192,17 +201,30 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
if (heading == null) {
setLoading(true)
} else {
val heading = heading.orientations.value
if (heading != null) {
setText(
"${heading[0].roundToInt()}°"
val headingSource =
if (compassHeading != null) carContext.getString(R.string.compass) else carContext.getString(
R.string.gps
)
} else {
setText(carContext.getString(R.string.auto_no_data))
}
setText("${heading.roundToInt()}° ($headingSource)")
setImage(
compass.draw(heading?.get(0)).asCarIcon()
compass.draw(heading).asCarIcon()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.coordinates))
if (location == null) {
setLoading(true)
} else {
val dms = location.formatDecimal(4)
setText(dms)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_location
)
).setTint(CarColor.DEFAULT).build()
)
}
}.build())
@@ -229,6 +251,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
override fun onResume(owner: LifecycleOwner) {
setupListeners()
session.mapScreen = this
}
private fun setupListeners() {
@@ -253,6 +276,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
override fun onPause(owner: LifecycleOwner) {
removeListeners()
session.mapScreen = null
}
private fun removeListeners() {
@@ -269,4 +293,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
it
) == PackageManager.PERMISSION_GRANTED
}
override fun updateLocation(location: Location) {
this.location = location
}
}

View File

@@ -7,6 +7,7 @@ import android.text.style.StyleSpan
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.car2go.maps.util.SphericalUtil
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.tasks.Tasks.await
@@ -19,6 +20,7 @@ import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRe
import com.google.android.libraries.places.api.net.PlacesStatusCodes
import kotlinx.coroutines.tasks.await
import net.vonforst.evmap.R
import java.io.IOException
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
@@ -58,6 +60,13 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
if (cause is ApiException) {
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else if (cause.statusCode in listOf(
CommonStatusCodes.NETWORK_ERROR,
CommonStatusCodes.TIMEOUT, CommonStatusCodes.RECONNECTION_TIMED_OUT,
CommonStatusCodes.RECONNECTION_TIMED_OUT_DURING_UPDATE
)
) {
throw IOException(cause)
}
}
throw e

View File

@@ -5,6 +5,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import com.android.billingclient.api.BillingClient.ProductType
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.adapter.Equatable
@@ -24,10 +25,15 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
loadProducts()
// consume pending purchases
val purchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP)
purchases.purchasesList?.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build()
) { _, purchasesList ->
purchasesList.forEach {
if (!it.isAcknowledged) {
consumePurchase(it.purchaseToken, false)
}
}
}
}
@@ -36,26 +42,26 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
private fun loadProducts() {
val params = SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.INAPP)
.setSkusList(
listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) +
if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
val productIds = listOf(
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
) + if (BuildConfig.DEBUG) {
listOf(
"android.test.purchased", "android.test.canceled",
"android.test.item_unavailable"
)
} else {
emptyList()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productIds.map {
QueryProductDetailsParams.Product.newBuilder().setProductType(ProductType.INAPP)
.setProductId(it).build()
})
.build()
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
billingClient.queryProductDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
products.postValue(Resource.success(details
.sortedBy { it.priceAmountMicros }
.sortedBy { it.oneTimePurchaseOfferDetails!!.priceAmountMicros }
.map { DonationItem(it) }
))
} else {
@@ -97,7 +103,13 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
fun startPurchase(it: DonationItem, activity: Activity) {
val flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(it.sku)
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(it.product)
.build()
)
)
.build()
val response = billingClient.launchBillingFlow(activity, flowParams)
if (response.responseCode != BillingClient.BillingResponseCode.OK) {
@@ -110,4 +122,4 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
}
}
data class DonationItem(val sku: SkuDetails) : Equatable
data class DonationItem(val product: ProductDetails) : Equatable

View File

@@ -28,7 +28,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.sku.title}"
android:text="@{item.product.title}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView21"
@@ -41,7 +41,7 @@
android:id="@+id/textView21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.sku.price}"
android:text="@{item.product.oneTimePurchaseOfferDetails.formattedPrice}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -28,9 +28,12 @@
<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

@@ -34,4 +34,5 @@
<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

@@ -34,4 +34,8 @@
<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>
<string name="auto_multipage_goto">Side %d</string>
<string name="auto_multipage">(%d/%d)</string>
</resources>

View File

@@ -28,9 +28,12 @@
<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">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

@@ -4,6 +4,7 @@
<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>
@@ -14,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
@@ -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" />

View File

@@ -8,8 +8,10 @@ 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
@@ -38,6 +40,7 @@ const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
const val EXTRA_FAVORITES = "favorites"
const val EXTRA_DONATE = "donate"
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
@@ -131,6 +134,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)
@@ -155,6 +189,11 @@ class MapsActivity : AppCompatActivity(),
.setGraph(navGraph)
.setDestination(R.id.favs)
.createPendingIntent()
} else if (intent.hasExtra(EXTRA_DONATE)) {
deepLink = navController.createDeepLink()
.setGraph(navGraph)
.setDestination(R.id.donate)
.createPendingIntent()
}
deepLink?.send()
@@ -196,6 +235,7 @@ class MapsActivity : AppCompatActivity(),
}
fun openUrl(url: String) {
val pkg = CustomTabsClient.getPackageName(this, null)
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -203,6 +243,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

@@ -72,7 +72,7 @@ fun max(a: Int?, b: Int?): Int? {
* otherwise the non-null value or null
*/
return if (a != null && b != null) {
max(a, b)
kotlin.math.max(a, b)
} else {
a ?: b
}

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

View File

@@ -10,6 +10,8 @@ import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import net.vonforst.evmap.utils.formatDMS
import net.vonforst.evmap.utils.formatDecimal
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle

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

@@ -132,7 +132,8 @@ 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 {

View File

@@ -152,13 +152,14 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
cp.connectors.map { connector ->
connector to cp.status
Triple(connector, cp.status, cp.evseId)
}
}
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
connectorStatus.forEachIndexed { index, (connector, statusStr) ->
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) {
@@ -179,17 +180,22 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
"UNSPECIFIED" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
enbwConnectors.put(id, power to type)
enbwStatus.put(id, status)
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"
"EnBW",
evseIds
)
}

View File

@@ -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,15 +162,20 @@ 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
)
}

View File

@@ -112,18 +112,47 @@ interface ChargepriceApi {
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) {
"goingelectric" -> country in listOf(
// list of countries according to Chargeprice.app, 2021/08/24
"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",

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 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
@@ -77,7 +80,9 @@ 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
)
@Resource("tariff")
@@ -178,9 +183,12 @@ data class ChargePrice(
@Json(name = "branding")
val branding: ChargepriceBranding? = null,
@ToOne("tariff")
val tariffId: String?
@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(
if (totalMonthlyFee > 0) {
@@ -193,12 +201,76 @@ data class ChargePrice(
}
}
/**
* 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
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()
return Relationships(members)
}
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?

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,111 @@
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
private interface FronyxApiRetrofit {
@GET("predictions/evse-id/{evseId}")
suspend fun getPredictionsForEvseId(
@Path("evseId") evseId: String,
@Query("timeframe") timeframe: Int? = null
): FronyxEvseIdResponse
@GET("predictions/evses")
suspend fun getPredictionsForEvseIds(
@Query("evseIds", encoded = true) evseIds: String // comma-separated
): List<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
): FronyxApiRetrofit {
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(FronyxApiRetrofit::class.java)
}
}
}
class FronyxApi(
apikey: String,
baseurl: String = "https://api.fronyx.io/api/",
context: Context? = null
) {
private val api = FronyxApiRetrofit.create(apikey, baseurl, context)
suspend fun getPredictionsForEvseId(
evseId: String,
timeframe: Int? = null
): FronyxEvseIdResponse = api.getPredictionsForEvseId(evseId, timeframe)
suspend fun getPredictionsForEvseIds(
evseIds: List<String>
): List<FronyxEvseIdResponse> = api.getPredictionsForEvseIds(evseIds.joinToString(","))
companion object {
/**
* 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,21 @@
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>,
val locationId: String?
)
@JsonClass(generateAdapter = true)
data class FronyxPrediction(
val timestamp: ZonedDateTime,
val status: FronyxStatus
)
enum class FronyxStatus {
AVAILABLE, UNAVAILABLE
}

View File

@@ -133,7 +133,7 @@ class OpenChargeMapApiWrapper(
}
val connectors = formatMultipleChoice(connectorsVal)
val operatorsVal = filters?.getMultipleChoiceValue("operators")!!
val operatorsVal = filters?.getMultipleChoiceValue("operators")
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
// no operators chosen
return Resource.success(emptyList())

View File

@@ -13,6 +13,7 @@ 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
@@ -24,10 +25,12 @@ 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
@@ -37,6 +40,7 @@ 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 = {
@@ -91,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)
@@ -117,7 +127,7 @@ class ChargepriceFragment : Fragment() {
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
binding.vehicleSelection.adapter = vehicleAdapter
headerBinding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar> = Observer {
vehicleAdapter.setCheckedItem(it)
}
@@ -133,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(
@@ -152,6 +166,9 @@ class ChargepriceFragment : Fragment() {
vm.myTariffsAll.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
it?.data?.let { chargepriceAdapter.submitList(it) }
}
val connectorsAdapter = CheckableConnectorAdapter()
@@ -170,7 +187,7 @@ class ChargepriceFragment : Fragment() {
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
}
binding.connectorsList.apply {
headerBinding.connectorsList.apply {
adapter = connectorsAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
@@ -183,12 +200,12 @@ class ChargepriceFragment : Fragment() {
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

@@ -98,6 +98,12 @@ class FilterFragment : Fragment(), MenuProvider {
saveProfile()
true
}
R.id.menu_reset -> {
lifecycleScope.launch {
vm.resetValues()
}
true
}
else -> false
}
}

View File

@@ -56,8 +56,7 @@ 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.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
@@ -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) {
@@ -237,6 +240,9 @@ 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)
@@ -252,6 +258,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
setupObservers()
setupClickListeners()
@@ -369,12 +376,25 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
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) {
@@ -553,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()
@@ -1168,6 +1189,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
)
popup.menuInflater.inflate(R.menu.popup_filter, popup.menu)
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_edit_filters -> {

View File

@@ -1,8 +1,13 @@
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
@@ -20,6 +25,9 @@ class UiSettingsFragment : BaseSettingsFragment() {
updateAppLocale(newValue as String)
true
}
val appLinkPref = findPreference<Preference>("applink_associate")!!
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
override fun onResume() {
@@ -34,4 +42,21 @@ class UiSettingsFragment : BaseSettingsFragment() {
}
}
}
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

@@ -19,8 +19,6 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
sealed class ChargepointListItem
@@ -343,28 +341,7 @@ data class ChargeLocationCluster(
) : ChargepointListItem()
@Parcelize
data class Coordinate(val lat: Double, val lng: Double) : Parcelable {
fun formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun formatDecimal(): String {
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
}
}
data class Coordinate(val lat: Double, val lng: Double) : Parcelable
@Parcelize
data class Address(
@@ -374,7 +351,21 @@ 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)
}
}
}
}

View File

@@ -93,6 +93,7 @@ class ChargeLocationsRepository(
id: Long
): LiveData<Resource<ChargeLocation>> {
return liveData {
emit(Resource.loading(null))
val refData = referenceData.await()
val result = api.value!!.getChargepointDetail(refData, id)
emit(result)

View File

@@ -1,29 +1,49 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.liveData
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.*
import net.vonforst.evmap.model.*
@Dao
abstract class FilterValueDao {
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun getBooleanFilterValues(
protected abstract suspend fun getBooleanFilterValuesAsync(
profile: Long,
dataSource: String
): List<BooleanFilterValue>
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun getMultipleChoiceFilterValues(
protected abstract suspend fun getMultipleChoiceFilterValuesAsync(
profile: Long,
dataSource: String
): List<MultipleChoiceFilterValue>
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun getSliderFilterValues(
protected abstract suspend fun getSliderFilterValuesAsync(
profile: Long,
dataSource: String
): List<SliderFilterValue>
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getBooleanFilterValues(
profile: Long,
dataSource: String
): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getMultipleChoiceFilterValues(
profile: Long,
dataSource: String
): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getSliderFilterValues(
profile: Long,
dataSource: String
): LiveData<List<SliderFilterValue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
@@ -58,15 +78,32 @@ abstract class FilterValueDao {
if (filterStatus == FILTERS_DISABLED || filterStatus == FILTERS_FAVORITES) {
emptyList()
} else {
getBooleanFilterValues(filterStatus, dataSource) +
getMultipleChoiceFilterValues(filterStatus, dataSource) +
getSliderFilterValues(filterStatus, dataSource)
getBooleanFilterValuesAsync(filterStatus, dataSource) +
getMultipleChoiceFilterValuesAsync(filterStatus, dataSource) +
getSliderFilterValuesAsync(filterStatus, dataSource)
}
open fun getFilterValues(filterStatus: Long, dataSource: String) = liveData {
emit(null)
emit(getFilterValuesAsync(filterStatus, dataSource))
}
open fun getFilterValues(filterStatus: Long, dataSource: String): LiveData<List<FilterValue>?> =
if (filterStatus == FILTERS_DISABLED || filterStatus == FILTERS_FAVORITES) {
MutableLiveData(emptyList())
} else {
MediatorLiveData<List<FilterValue>?>().apply {
value = null
val sources = listOf(
getBooleanFilterValues(filterStatus, dataSource),
getMultipleChoiceFilterValues(filterStatus, dataSource),
getSliderFilterValues(filterStatus, dataSource)
)
for (source in sources) {
addSource(source) {
val values = sources.map { it.value }
if (values.all { it != null }) {
value = values.filterNotNull().flatten()
}
}
}
}
}
@Transaction
open suspend fun insert(vararg values: FilterValue) {

View File

@@ -246,4 +246,19 @@ class PreferenceDataSource(val context: Context) {
set(value) {
sp.edit().putString("place_search_result_android_auto_name", value).apply()
}
var showChargersAheadAndroidAuto: Boolean
get() = sp.getBoolean("show_chargers_ahead_android_auto", false)
set(value) {
sp.edit().putBoolean("show_chargers_ahead_android_auto", value).apply()
}
val predictionEnabled: Boolean
get() = sp.getBoolean("prediction_enabled", true)
var developerModeEnabled: Boolean
get() = sp.getBoolean("dev_mode_enabled", false)
set(value) {
sp.edit().putBoolean("dev_mode_enabled", value).apply()
}
}

View File

@@ -0,0 +1,352 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.*
import android.text.Layout
import android.text.SpannableString
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val dp = context.resources.displayMetrics.density
private val sp = context.resources.displayMetrics.scaledDensity
var zeroHeight = 4 * dp
var barWidth = (16 * dp).roundToInt()
var barMargin = (2 * dp).roundToInt()
var legendWidth = 12 * dp
var legendLineLength = 4 * dp
var legendLineWidth = 1 * dp
var dashLength = 4 * dp
var bubbleTextSize = (12 * sp).roundToInt()
var bubblePadding = (6 * dp).roundToInt()
var selectedBar: Int = 0
var bubbleStrokeWidth = 1 * dp
var barDrawable =
AppCompatResources.getDrawable(context, R.drawable.bar_graph)!!
var colorAvailable = ContextCompat.getColor(context, R.color.available)
var colorUnavailable = ContextCompat.getColor(context, R.color.unavailable)
var data: Map<ZonedDateTime, Int>? = null
set(value) {
field = value
invalidate()
}
var maxValue: Int? = null
set(value) {
field = value
invalidate()
}
var activeAlpha = 0.87f
var inactiveAlpha = 0.60f
private val legendPaint = Paint().apply {
val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal))
color = ta.getColor(0, 0)
strokeWidth = legendLineWidth
textSize = legendWidth - legendLineLength
}
private val legendDashedPaint = Paint().apply {
set(legendPaint)
alpha = (inactiveAlpha * 255).roundToInt()
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(dashLength, dashLength), 0f)
strokeWidth = 1f
}
private val bubblePaint = Paint().apply {
set(legendPaint)
alpha = (inactiveAlpha * 255).roundToInt()
style = Paint.Style.STROKE
strokeWidth = bubbleStrokeWidth
}
private val bubbleTextPaint = TextPaint().apply {
set(legendPaint)
textSize = bubbleTextSize.toFloat()
}
private var graphBounds: Rect? = null
private var bubbleBounds: Rect? = null
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val bottom = (paddingBottom + legendWidth).roundToInt()
val left = (paddingLeft + legendWidth).roundToInt()
val right = (paddingRight + legendWidth).roundToInt()
val top = (paddingTop + bubbleStrokeWidth / 2).roundToInt()
val bubbleTextHeight = bubbleTextPaint.fontMetrics.run { descent - ascent }
val bubbleHeight = (bubbleTextHeight + 3 * bubblePadding).roundToInt()
val bubbleLeft = (paddingLeft + bubbleStrokeWidth / 2).roundToInt()
val bubbleRight = (paddingRight + bubbleStrokeWidth / 2).roundToInt()
graphBounds = Rect(left, top + bubbleHeight, w - right, h - bottom)
bubbleBounds = Rect(bubbleLeft, top, w - bubbleRight, top + bubbleHeight)
}
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
override fun onDraw(canvas: Canvas) {
if (isInEditMode && data == null) {
// show sample data
val now = ZonedDateTime.now().run {
val minutesRound = ((minute / 15) + 1) * 15
plusMinutes((minutesRound - minute).toLong())
}
data = (0..20).associate {
now.plusMinutes(15L * it) to (Math.random() * 8).roundToInt()
}
maxValue = 8
}
val data = data?.toSortedMap() ?: return
if (data.isEmpty()) return
val maxValue = maxValue ?: data.maxOf { it.value }
drawGraph(canvas, data, maxValue)
drawBubble(canvas, data, maxValue)
}
private fun drawGraph(
canvas: Canvas,
data: SortedMap<ZonedDateTime, Int>,
maxValue: Int
) {
val graphBounds = graphBounds ?: return
canvas.apply {
drawLine(
graphBounds.left.toFloat(),
graphBounds.top.toFloat(),
graphBounds.right.toFloat(),
graphBounds.top.toFloat(),
legendDashedPaint
)
legendPaint.textAlign = Paint.Align.CENTER
data.entries.forEachIndexed { i, (t, v) ->
val height =
zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue
val left = graphBounds.left + (barWidth + barMargin) * i
if (left + barWidth > graphBounds.right) return@forEachIndexed
barDrawable.setBounds(
left,
graphBounds.bottom - height.roundToInt(),
left + barWidth,
graphBounds.bottom
)
barDrawable.alpha =
((if (i == selectedBar) activeAlpha else inactiveAlpha) * 255).roundToInt()
barDrawable.setTint(getColor(v, maxValue))
barDrawable.draw(canvas)
val center = left.toFloat() + barWidth / 2
if (t.minute == 0) {
drawLine(
center, graphBounds.bottom.toFloat(),
center, graphBounds.bottom + legendLineLength, legendPaint
)
drawText(
t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat),
center, graphBounds.bottom + legendWidth, legendPaint
)
}
if (i == selectedBar) {
drawLine(
center,
graphBounds.bottom - height,
center,
graphBounds.top.toFloat(),
legendDashedPaint
)
}
}
drawLine(
graphBounds.left.toFloat(),
graphBounds.bottom.toFloat(),
graphBounds.right.toFloat(),
graphBounds.bottom.toFloat(),
legendPaint
)
drawLine(
graphBounds.left.toFloat(),
graphBounds.bottom.toFloat(),
graphBounds.right.toFloat(),
graphBounds.bottom.toFloat(),
legendPaint
)
legendPaint.textAlign = Paint.Align.LEFT
drawText(
this@BarGraphView.maxValue.toString(),
graphBounds.right.toFloat() + legendLineLength,
graphBounds.top + (legendWidth - legendLineLength) / 3,
legendPaint
)
}
}
private fun getColor(v: Int, maxValue: Int) =
if (v < maxValue) colorAvailable else colorUnavailable
private fun drawBubble(canvas: Canvas, data: SortedMap<ZonedDateTime, Int>, maxValue: Int) {
val bubbleBounds = bubbleBounds ?: return
val graphBounds = graphBounds ?: return
val data = data.toList()
if (data.size <= selectedBar) return
canvas.apply {
val center = graphBounds.left + selectedBar * (barWidth + barMargin) + barWidth * 0.5f
val (t, v) = data[selectedBar]
val tformat = context.getString(
R.string.prediction_time_colon,
t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat)
)
val availableformat = context.resources.getQuantityString(
R.plurals.prediction_number_available,
maxValue - v,
maxValue - v,
maxValue
)
val text = SpannableString("$tformat $availableformat").apply {
setSpan(
ForegroundColorSpan(getColor(v, maxValue)),
0,
tformat.length + 1,
SpannableString.SPAN_INCLUSIVE_INCLUSIVE
)
setSpan(
StyleSpan(Typeface.BOLD),
0,
tformat.length + 1,
SpannableString.SPAN_INCLUSIVE_INCLUSIVE
)
}
val bubbleTextWidth = StaticLayout.getDesiredWidth(text, bubbleTextPaint)
val bubbleWidth = bubbleTextWidth + 2 * bubblePadding
val bubbleLeft = max(
min(center - bubbleWidth / 2, bubbleBounds.right - bubbleWidth),
bubbleBounds.left.toFloat()
)
val bubblePath = generateBubblePath(
center,
bubbleBounds.bottom.toFloat(),
bubbleLeft,
bubbleBounds.top.toFloat(),
bubbleLeft + bubbleWidth,
(bubbleBounds.bottom - bubblePadding).toFloat(),
bubblePadding.toFloat()
)
drawPath(bubblePath, bubblePaint)
val layout = StaticLayout(
text,
bubbleTextPaint,
ceil(bubbleTextWidth).toInt(),
Layout.Alignment.ALIGN_NORMAL,
1f,
0f,
false
)
canvas.save()
canvas.translate(bubbleLeft + bubblePadding, bubbleBounds.top + bubblePadding.toFloat())
layout.draw(canvas)
canvas.restore()
//drawText(text, 0, text.length, bubbleLeft + bubblePadding, bubbleBounds.top + 2f * bubblePadding, bubbleTextPaint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val graphBounds = graphBounds ?: return super.onTouchEvent(event)
val x = event.x.roundToInt()
val y = event.y.roundToInt()
if (graphBounds.contains(x, y) && event.action == MotionEvent.ACTION_DOWN) {
parent.requestDisallowInterceptTouchEvent(true)
updateSelectedBar(x)
return true
} else if (event.action == MotionEvent.ACTION_MOVE && x > graphBounds.left && y < graphBounds.right) {
updateSelectedBar(x)
return true
} else if (event.action == MotionEvent.ACTION_UP) {
parent.requestDisallowInterceptTouchEvent(false)
return true
}
return super.onTouchEvent(event)
}
private fun updateSelectedBar(x: Int) {
val graphBounds = graphBounds ?: return
val bar = (x - graphBounds.left) / (barWidth + barMargin)
if (bar != selectedBar) {
selectedBar = bar
invalidate()
}
}
/**
* Generates a path that represents a "speech bubble" with tip position at tipX, tipY,
* bubble bounds left, top, right bottom and corner radius cornerRadius.
*/
private fun generateBubblePath(
tipX: Float,
tipY: Float,
left: Float,
top: Float,
right: Float,
bottom: Float,
cornerRadius: Float
): Path {
val tipWidth = tipY - bottom
return Path().apply {
moveTo(tipX, tipY)
lineTo(min(tipX + tipWidth, right - cornerRadius), bottom)
lineTo(right - cornerRadius, bottom)
arcTo(
right - cornerRadius * 2,
bottom - cornerRadius * 2,
right,
bottom,
90f,
-90f,
false
)
lineTo(right, top + cornerRadius)
arcTo(right - cornerRadius * 2, top, right, top + cornerRadius * 2, 0f, -90f, false)
lineTo(left + cornerRadius, top)
arcTo(left, top, left + cornerRadius * 2, top + cornerRadius * 2, 270f, -90f, false)
lineTo(left, bottom - cornerRadius)
arcTo(
left,
bottom - cornerRadius * 2,
left + cornerRadius * 2,
bottom,
180f,
-90f,
false
)
lineTo(max(tipX - tipWidth, left + cornerRadius), bottom)
close()
}
}
}

View File

@@ -8,6 +8,8 @@ import android.location.Location
import androidx.core.content.ContextCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.model.Coordinate
import java.util.*
import kotlin.math.*
/**
@@ -47,6 +49,25 @@ fun distanceBetween(
}
fun bearingBetween(startLat: Double, startLng: Double, endLat: Double, endLng: Double): Double {
val dLon = Math.toRadians(endLng) - Math.toRadians(startLng)
val originLat = Math.toRadians(startLat)
val destinationLat = Math.toRadians(endLat)
return Math.toDegrees(
atan2(
sin(dLon) * cos(destinationLat),
cos(originLat) * sin(destinationLat) - sin(originLat) * cos(destinationLat) * cos(dLon)
)
)
}
fun headingDiff(h1: Double, h2: Double): Double {
return (h1 - h2 + 540) % 360 - 180
}
fun getLocationFromIntent(intent: Intent): List<Double>? {
val pos = intent.data?.schemeSpecificPart?.split("?")?.get(0)
var coords = stringToCoords(pos)
@@ -92,4 +113,33 @@ fun Context.checkAnyLocationPermission() = ContextCompat.checkSelfPermission(
fun Context.checkFineLocationPermission() = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) == PackageManager.PERMISSION_GRANTED
fun Coordinate.formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
fun Location.formatDMS(): String {
return "${dms(latitude, false)}, ${dms(longitude, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun Coordinate.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng)
}
fun Location.formatDecimal(accuracy: Int = 6): String {
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude)
}

View File

@@ -166,7 +166,7 @@ class ChargepriceViewModel(
)
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
@@ -243,6 +243,12 @@ class ChargepriceViewModel(
}
val cpStation = ChargepriceStation.fromEvmap(charger, compatibleConnectors)
if (cpStation.chargePoints.isEmpty()) {
// no compatible connectors
chargePrices.value = Resource.success(emptyList())
chargePriceMeta.value = Resource.success(ChargepriceMeta(emptyList()))
return
}
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
@@ -257,7 +263,8 @@ class ChargepriceViewModel(
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!myTariffsAll) {
Relationships(

View File

@@ -61,9 +61,10 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
prefs.filterStatus = FILTERS_CUSTOM
}
suspend fun saveAsProfile(name: String) {
suspend fun saveAsProfile(name: String): Boolean {
// get or create profile
var profileId = db.filterProfileDao().getProfileByName(name, prefs.dataSource)?.id
if (profileId == null) {
profileId = db.filterProfileDao().getNewId(prefs.dataSource)
db.filterProfileDao().insert(FilterProfile(name, prefs.dataSource, profileId))
@@ -81,6 +82,8 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
// set selected profile
prefs.filterStatus = profileId
return true
}
suspend fun deleteCurrentProfile() {
@@ -89,4 +92,8 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
prefs.filterStatus = FILTERS_DISABLED
}
}
suspend fun resetValues() {
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
}
}

View File

@@ -6,14 +6,22 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
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.equivalentPlugTypes
import net.vonforst.evmap.api.fronyx.FronyxApi
import net.vonforst.evmap.api.fronyx.FronyxEvseIdResponse
import net.vonforst.evmap.api.fronyx.FronyxStatus
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.stringProvider
@@ -25,6 +33,9 @@ import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.utils.distanceBetween
import retrofit2.HttpException
import java.io.IOException
import java.time.ZonedDateTime
@Parcelize
data class MapPosition(val bounds: LatLngBounds, val zoom: Float) : Parcelable
@@ -61,6 +72,21 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
state.getLiveData("bottomSheetState")
}
val bottomSheetExpanded = MediatorLiveData<Boolean>().apply {
addSource(bottomSheetState) {
when (it) {
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED,
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
value = false
}
BottomSheetBehaviorGoogleMapsLike.STATE_EXPANDED,
BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT -> {
value = true
}
}
}
}.distinctUntilChanged()
val mapPosition: MutableLiveData<MapPosition> by lazy {
state.getLiveData("mapPosition")
}
@@ -202,6 +228,123 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
addSource(filteredMinPower, callback)
}
}
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
availability.switchMap { av ->
if (!prefs.predictionEnabled) return@switchMap null
av.data?.evseIds?.let { evseIds ->
liveData {
emit(Resource.loading(null))
val charger = charger.value?.data ?: return@liveData
val allEvseIds =
evseIds.filterKeys {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(
it.type
).any { filtered.contains(it) }
} ?: true
}.flatMap { it.value }
try {
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
if (result.size == allEvseIds.size) {
emit(Resource.success(result))
} else {
emit(Resource.error("not all EVSEIDs found", null))
}
} catch (e: IOException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: HttpException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
} catch (e: AvailabilityDetectorException) {
emit(Resource.error(e.message, null))
e.printStackTrace()
}
}
} ?: liveData { emit(Resource.success(null)) }
}
}
val predictionGraph: LiveData<Map<ZonedDateTime, Int>?> by lazy {
prediction.map {
it.data?.let { responses ->
if (responses.isEmpty()) {
null
} else {
val evseIds = responses.map { it.evseId }
val groupByTimestamp = responses.flatMap { response ->
response.predictions.map {
Triple(
it.timestamp,
response.evseId,
it.status
)
}
}
.groupBy { it.first } // group by timestamp
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
groupByTimestamp.mapValues {
it.value.count {
it.second == FronyxStatus.UNAVAILABLE
}
}.ifEmpty { null }
}
}
}
}
private val predictedChargepoints = charger.map {
it.data?.let { charger ->
charger.chargepoints.filter {
FronyxApi.isChargepointSupported(charger, it) &&
filteredConnectors.value?.let { filtered ->
equivalentPlugTypes(it.type).any {
filtered.contains(
it
)
}
} ?: true
}
}
}
val predictionMaxValue: LiveData<Int> by lazy {
predictedChargepoints.map {
it?.sumOf { it.count } ?: 0
}
}
val predictionDescription: LiveData<String?> by lazy {
predictedChargepoints.map { predictedChargepoints ->
if (predictedChargepoints == null) return@map null
val allChargepoints = charger.value?.data?.chargepoints ?: return@map null
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
if (allChargepoints == predictedChargepoints) {
null
} else if (predictedChargepointTypes.size == 1) {
application.getString(
R.string.prediction_only,
nameForPlugType(application.stringProvider(), predictedChargepointTypes[0])
)
} else {
application.getString(
R.string.prediction_only,
application.getString(R.string.prediction_dc_plugs_only)
)
}
}
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
@@ -344,8 +487,6 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
chargepointsInternal?.let { chargepoints.removeSource(it) }
chargepointsInternal = result
chargepoints.addSource(result) {
chargepoints.value = it
val apiId = apiId.value
when (apiId) {
"going_electric" -> {
@@ -378,6 +519,8 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
filteredChargeCards.value = null
}
}
chargepoints.value = it
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/black" />
<corners
android:topLeftRadius="2dp"
android:topRightRadius="2dp" />
</shape>

View File

@@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M10.83,8H21V6H8.83L10.83,8zM15.83,13H18v-2h-4.17L15.83,13zM14,16.83V18h-4v-2h3.17l-3,-3H6v-2h2.17l-3,-3H3V6h0.17L1.39,4.22l1.41,-1.41l18.38,18.38l-1.41,1.41L14,16.83z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M3,10h11v2H3V10zM3,8h11V6H3V8zM3,16h7v-2H3V16zM18.01,12.87l0.71,-0.71c0.39,-0.39 1.02,-0.39 1.41,0l0.71,0.71c0.39,0.39 0.39,1.02 0,1.41l-0.71,0.71L18.01,12.87zM17.3,13.58l-5.3,5.3V21h2.12l5.3,-5.3L17.3,13.58z" />
</vector>

View File

@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="566.9dp"
android:height="254.9dp"
android:viewportWidth="566.9"
android:viewportHeight="254.9">
<path
android:pathData="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.2c0,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.3c7.3,-7.4 19,-10.8 35.1,-10V86.3M157.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.3C168.3,188.5 157.1,176.9 157.1,161.2M182.8,110.1c-27.9,0 -49.7,22.2 -49.7,51.1c0,29.1 21.8,51.3 49.7,51.3c27.9,0 49.4,-22.2 49.4,-51.3C232.1,132.3 210.7,110.1 182.8,110.1M541.4,161.5c14.1,0 25.6,-11.5 25.6,-25.6c0,-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.5M129.6,110.5c-2.2,-0.2 -4.3,-0.4 -6.6,-0.4c-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.8c1.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.7c0,-0.9 0.2,-1.7 0.2,-2.6C129.6,110.6 129.6,110.5 129.6,110.5zM475.8,160.6l29.7,-49h-28.7l-16.3,31.7l-16.5,-31.7H415l30.1,49l-30.7,50.6h28.7l17.3,-33.1l17.7,33.1h28.7L475.8,160.6zM356.9,254.8c14.9,0.5 26.7,-3.1 34.7,-10.9c7.6,-7.4 11.6,-18.1 12,-33l0,0v-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.5c-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-6c0,-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.2c3.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,-3c1.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"
android:fillColor="#000044" />
<path
android:pathData="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.9c2.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.7c-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.6zM117.3,66.8c4,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.2c-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"
android:fillColor="#000044" />
<path
android:pathData="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.6c0,-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.5c1.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.4zM165.4,66.8c4,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.2c-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"
android:fillColor="#000044" />
<path
android:pathData="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.6l-13.6,-37.7h3.4l-13.7,37.7H207.2z"
android:fillColor="#000044" />
<path
android:pathData="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.6c3,-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.1c0,-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,12c2.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.2C293.8,71.9 290.8,72.4 287.7,72.4z"
android:fillColor="#000044" />
<path
android:pathData="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.2c1.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.5s-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"
android:fillColor="#000044" />
<path
android:pathData="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.6c3,-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.1c0,-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,12c2.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.2C370,71.9 367,72.4 363.9,72.4z"
android:fillColor="#000044" />
<path
android:pathData="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.7c0,-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.8v61.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.4zM408.2,66.8c4,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.2c-2.4,2.8 -3.6,6.8 -3.6,12.2c0,5.3 1.2,9.4 3.6,12.1C401.1,65.4 404.3,66.8 408.2,66.8z"
android:fillColor="#000044" />
<path
android:pathData="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-1c1,-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.7s-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.4zM482.9,66.8c4,0 7.2,-1.4 9.6,-4.1c2.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.2c0,5.3 1.2,9.4 3.6,12.1C475.8,65.4 478.9,66.8 482.9,66.8z"
android:fillColor="#000044" />
<path
android:pathData="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.9l-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.7S514.1,90.3 511.9,90.7z"
android:fillColor="#000044" />
</vector>

View File

@@ -5,6 +5,10 @@
<data>
<import type="java.util.Map" />
<import type="java.time.ZonedDateTime" />
<import type="net.vonforst.evmap.model.ChargeLocation" />
<import type="net.vonforst.evmap.model.Chargepoint" />
@@ -39,6 +43,18 @@
name="availability"
type="Resource&lt;ChargeLocationStatus&gt;" />
<variable
name="predictionGraph"
type="Map&lt;ZonedDateTime, Integer&gt;" />
<variable
name="predictionMaxValue"
type="Integer" />
<variable
name="predictionDescription"
type="String" />
<variable
name="filteredAvailability"
type="Resource&lt;ChargeLocationStatus&gt;" />
@@ -61,7 +77,8 @@
</data>
<androidx.cardview.widget.CardView
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="@dimen/detail_corner_radius"
@@ -80,15 +97,15 @@
android:id="@+id/txtName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="30dp"
android:ellipsize="end"
android:hyphenationFrequency="normal"
android:maxLines="@{expanded ? 3 : 1}"
android:text="@{charger.data.name}"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
app:layout_constraintEnd_toStartOf="@+id/imgFaultReport"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
@@ -136,8 +153,8 @@
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{BindingAdaptersKt.flatten(filteredAvailability.data.status.values())}"
app:invisibleUnlessAnimated="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:invisibleUnless="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:invisibleUnlessAnimated="@{filteredAvailability.data != null &amp;&amp; !expanded}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/txtName"
tools:backgroundTint="@color/available"
@@ -175,10 +192,11 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/connectors"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintEnd_toStartOf="@+id/btnRefreshLiveData"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtConnectors" />
@@ -201,10 +219,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@{charger.data.amenities}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.amenities}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.amenities != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
@@ -231,10 +249,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@{charger.data.generalInformation}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:autoLink="web"
android:linksClickable="true"
android:text="@{charger.data.generalInformation}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:goneUnless="@{charger.data.generalInformation != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintHorizontal_bias="0.0"
@@ -260,13 +278,12 @@
android:id="@+id/details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false"
app:data="@{DetailsAdapterKt.buildDetails(charger.data, chargeCards, filteredChargeCards, context)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnChargeprice"
app:layout_constraintTop_toBottomOf="@+id/divider3"
tools:itemCount="3"
tools:listitem="@layout/item_detail" />
@@ -287,17 +304,17 @@
android:text="@{@string/source(apiName)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView4" />
app:layout_constraintTop_toBottomOf="@+id/textView4"
tools:text="Source: DataSource" />
<TextView
android:id="@+id/textView13"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="right|end"
android:text="@{availability.status == Status.SUCCESS ? @string/realtime_data_source(availability.data.source) : availability.status == Status.LOADING ? @string/realtime_data_loading : @string/realtime_data_unavailable}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintEnd_toStartOf="@+id/btnRefreshLiveData"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/connectors"
tools:text="Echtzeitdaten nicht verfügbar" />
@@ -306,8 +323,8 @@
android:id="@+id/topPart"
android:layout_width="0dp"
android:layout_height="0dp"
android:text="TextView"
android:layout_marginBottom="-10dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="@+id/txtConnectors"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
@@ -321,43 +338,138 @@
android:layout_marginTop="8dp"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; charger.data.chargepriceData != null &amp;&amp; charger.data.chargepriceData.country != null &amp;&amp; ChargepriceApi.isCountrySupported(charger.data.chargepriceData.country, charger.data.dataSource)}"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider1" />
<View
android:id="@+id/divider2"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:layout_constraintTop_toBottomOf="@+id/textView13" />
<View
android:id="@+id/divider3"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:layout_constraintTop_toBottomOf="@+id/btnChargeprice" />
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/divider2" />
<TextView
android:id="@+id/textView29"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@{predictionDescription}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
tools:text="(DC plugs only)" />
<Button
android:id="@+id/btnPredictionHelp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionGraph != null}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView8" />
<net.vonforst.evmap.ui.BarGraphView
android:id="@+id/prediction"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
app:data="@{predictionGraph}"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionMaxValue}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
tools:orientation="horizontal" />
<ImageView
android:id="@+id/imgPredictionSource"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"
app:tint="@color/logo_tint_night" />
<View
android:id="@+id/divider1"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?android:attr/listDivider"
app:goneUnless="@{predictionGraph != null}"
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
<ImageView
android:id="@+id/imgVerified"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:layout_marginEnd="8dp"
android:contentDescription="@string/verified"
app:tooltipTextCompat="@{@string/verified_desc(apiName)}"
app:goneUnless="@{ charger.data.verified }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/imgFaultReport"
app:layout_constraintTop_toTopOf="@+id/txtName"
app:srcCompat="@drawable/ic_verified"
app:tint="@color/available"
app:tooltipTextCompat="@{@string/verified_desc(apiName)}"
tools:targetApi="o" />
<ImageView
android:id="@+id/imgFaultReport"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:contentDescription="@string/fault_report"
app:tooltipTextCompat="@{@string/fault_report}"
app:goneUnless="@{ charger.data.faultReport != null }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintEnd_toStartOf="@+id/imgVerified"
app:layout_constraintStart_toEndOf="@+id/txtName"
app:layout_constraintTop_toTopOf="@+id/txtName"
app:srcCompat="@drawable/ic_map_marker_fault"
app:tooltipTextCompat="@{@string/fault_report}"
tools:targetApi="o" />
<TextView
@@ -365,31 +477,31 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:breakStrategy="balanced"
android:text="@{charger.data.license}"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textStyle="italic"
android:text="@{charger.data.license}"
android:breakStrategy="balanced"
app:goneUnless="@{charger.data.license != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="The data is provided under the National Oman Open Data LicensE (NOODLE), Version 3.14, and may be used for any purpose whatsoever." />
<Button
android:id="@+id/btnRefreshLiveData"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.App.Button.OutlinedButton.IconOnly.Small"
android:contentDescription="@string/refresh_live_data"
android:enabled="@{availability.status != Status.LOADING}"
app:icon="@drawable/ic_refresh"
app:layout_constraintBottom_toBottomOf="@+id/textView13"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/connectors" />
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView7"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/textView7" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</com.google.android.material.card.MaterialCardView>
</layout>

View File

@@ -14,7 +14,7 @@
type="ChargepriceViewModel" />
</data>
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout5"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -54,192 +54,87 @@
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
tools:itemCount="1"
tools:listitem="@layout/fragment_chargeprice_preview" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ProgressBar
android:id="@+id/progressBar5"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="280dp"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.LOADING || vm.vehicles.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="@+id/charge_prices_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_select_connector"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/vehicle_selection" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="280dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_tariffs_found"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.SUCCESS &amp;&amp; vm.chargePricesForChargepoint.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:data="@{vm.charger.chargepointsMerged}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_connector_button"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="280dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_compatible_connectors"
app:goneUnless="@{vm.noCompatibleConnectors}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="280dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_select_car_first"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list"
app:layout_constraintVertical_chainStyle="packed" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.energy / vm.chargepriceMetaForChargepoint.data.duration * 60)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="(18 kWh, approx. 23 min, ⌀ 50 kW)" />
<TextView
android:id="@+id/tvVehicleHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_vehicle"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vehicle_selection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvVehicleHeader"
app:data="@{vm.vehicles.data}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
android:orientation="horizontal"
tools:listitem="@layout/item_chargeprice_vehicle_chip" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_range"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:valueFrom="0.0"
android:valueTo="100.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView4"
app:values="@={vm.batteryRange}" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:nestedScrollingEnabled="false"
app:data="@{vm.chargePricesForChargepoint.data}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/battery_range"
tools:itemCount="3"
tools:listitem="@layout/item_chargeprice" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_tariffs_found"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.SUCCESS &amp;&amp; vm.chargePricesForChargepoint.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_compatible_connectors"
app:goneUnless="@{vm.noCompatibleConnectors}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_select_car_first"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list"
app:layout_constraintVertical_chainStyle="packed" />
<ProgressBar
android:id="@+id/progressBar5"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.LOADING || vm.vehicles.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="@+id/charge_prices_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.viewmodel.ChargepriceViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="vm"
type="ChargepriceViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_select_connector"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/vehicle_selection" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:data="@{vm.charger.chargepointsMerged}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_connector_button"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.energy / vm.chargepriceMetaForChargepoint.data.duration * 60)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="(18 kWh, approx. 23 min, ⌀ 50 kW)" />
<TextView
android:id="@+id/tvVehicleHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_vehicle"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vehicle_selection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvVehicleHeader"
app:data="@{vm.vehicles.data}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
android:orientation="horizontal"
tools:listitem="@layout/item_chargeprice_vehicle_chip" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_range"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:valueFrom="0.0"
android:valueTo="100.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView4"
app:values="@={vm.batteryRange}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/fragment_chargeprice_header" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
</LinearLayout>

View File

@@ -19,7 +19,8 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:clipChildren="false">
<FrameLayout
android:id="@+id/map"
@@ -142,7 +143,7 @@
<FrameLayout
android:id="@+id/gallery_container"
android:layout_width="match_parent"
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="@dimen/gallery_height_with_margin"
android:background="?android:colorBackground"
app:layout_behavior="@string/BackDropBottomSheetBehavior">
@@ -174,11 +175,11 @@
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:isFabActive="@{ vm.myLocationEnabled }"
app:layout_behavior="net.vonforst.evmap.ui.HideOnScrollFabBehavior" />
app:layout_behavior="@string/hide_on_scroll_fab_behavior" />
<androidx.core.widget.NestedScrollView
android:id="@+id/bottom_sheet"
android:layout_width="match_parent"
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="match_parent"
android:fillViewport="true"
android:orientation="vertical"
@@ -187,6 +188,7 @@
app:behavior_peekHeight="@dimen/peek_height"
app:bottomsheetbehavior_defaultState="stateHidden"
app:layout_behavior="@string/BottomSheetBehaviorGoogleMapsLike"
android:clipToPadding="false"
tools:bottomsheetbehavior_defaultState="stateCollapsed">
<include
@@ -195,10 +197,13 @@
app:charger="@{vm.charger}"
app:availability="@{vm.availability}"
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionGraph="@{vm.predictionGraph}"
app:predictionMaxValue="@{vm.predictionMaxValue}"
app:predictionDescription="@{vm.predictionDescription}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &amp;&amp; vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}"
app:expanded="@{vm.bottomSheetExpanded}"
app:apiName="@{vm.apiName}" />
</androidx.core.widget.NestedScrollView>
@@ -211,6 +216,7 @@
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_directions"
android:translationX="@dimen/directions_fab_translationx"
app:layout_anchor="@id/bottom_sheet"
app:layout_anchorGravity="top|right|end"
app:layout_behavior="@string/ScrollAwareFABBehavior"
@@ -218,12 +224,13 @@
<com.mahc.custombottomsheetbehavior.MergedAppBarLayout
android:id="@+id/detail_app_bar"
android:layout_width="match_parent"
android:layout_width="@dimen/map_toolbar_width"
android:layout_height="wrap_content"
app:layout_behavior="@string/MergedAppBarLayoutBehavior"
android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
style="@style/Widget.Material3.FloatingActionButton.Small.Surface"
android:id="@+id/fab_layers"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -233,11 +240,9 @@
android:layout_marginEnd="20dp"
android:layout_marginTop="@dimen/layers_fab_top_padding"
app:tint="?android:colorControlNormal"
app:backgroundTint="?android:colorBackground"
app:borderWidth="0dp"
app:fabSize="mini"
app:srcCompat="@drawable/ic_layers"
app:layout_behavior="net.vonforst.evmap.ui.HideOnExpandFabBehavior"
app:layout_behavior="@string/hide_on_scroll_fab_behavior"
android:theme="@style/NoElevationOverlay" />
<androidx.cardview.widget.CardView

View File

@@ -110,7 +110,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="end"
android:text="@{String.format(@string/charge_price_format, item.chargepointPrices.get(0).price, BindingAdaptersKt.currency(item.currency))}"
android:text="@{item.chargepointPrices.get(0).price != null ? String.format(@string/charge_price_format, item.chargepointPrices.get(0).price, BindingAdaptersKt.currency(item.currency)) : @string/chargeprice_price_not_available}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@+id/txtAveragePrice"
app:layout_constraintEnd_toEndOf="parent"
@@ -125,8 +125,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="end"
android:text="@{String.format(item.chargepointPrices.get(0).priceDistribution.isOnlyKwh ? @string/charge_price_kwh_format : @string/charge_price_average_format, item.chargepointPrices.get(0).price / meta.energy, BindingAdaptersKt.currency(item.currency))}"
app:goneUnless="@{item.chargepointPrices.get(0).price > 0}"
android:text="@{item.chargepointPrices.get(0).price != null ? String.format(item.chargepointPrices.get(0).priceDistribution.isOnlyKwh ? @string/charge_price_kwh_format : @string/charge_price_average_format, item.chargepointPrices.get(0).price / meta.energy, BindingAdaptersKt.currency(item.currency)) : item.chargepointPrices.get(0).noPriceReason}"
app:goneUnless="@{item.chargepointPrices.get(0).price > 0 || item.chargepointPrices.get(0).price == null}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toTopOf="@id/txtPriceDetails"
app:layout_constraintEnd_toEndOf="parent"
@@ -162,7 +162,7 @@
android:layout_height="40dp"
android:layout_margin="8dp"
android:scaleType="fitCenter"
app:goneUnless="@{item.branding.logoUrl != null}"
app:invisibleUnless="@{item.branding.logoUrl != null}"
app:imageUrl="@{item.branding.logoUrl}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline5"

View File

@@ -39,7 +39,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="38dp"
android:layout_marginTop="38dp"
android:text="@{String.format(&quot;× %d&quot;, item.chargepoint.count)}"
android:text="@{String.format(&quot;\u00D7 %d&quot;, item.chargepoint.count)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView"

View File

@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_reset"
android:title="@string/menu_reset"
android:icon="@drawable/ic_filter_no"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_save_profile"
android:title="@string/menu_save_profile"

View File

@@ -8,9 +8,11 @@
<item
android:id="@+id/menu_edit_filters"
android:title="@string/menu_edit_filters"
android:menuCategory="secondary" />
android:menuCategory="secondary"
android:icon="@drawable/ic_edit" />
<item
android:id="@+id/menu_manage_filter_profiles"
android:title="@string/menu_manage_filter_profiles"
android:menuCategory="secondary" />
android:menuCategory="secondary"
android:icon="@drawable/ic_manage_filter_profiles" />
</menu>

View File

@@ -101,6 +101,7 @@
<string name="pref_language">App-Sprache</string>
<string name="pref_darkmode">Dunkles Design</string>
<string name="connection_error">Ladesäulen konnten nicht geladen werden</string>
<string name="location_error">Standort nicht erkannt. Bitte Systemeinstellungen prüfen</string>
<string name="retry">Wiederholen</string>
<string name="filter_open_247">24 Stunden geöffnet</string>
<string name="filter_barrierfree">Ohne Vertrag / Registrierung nutzbar</string>
@@ -142,13 +143,14 @@
<string name="category_caravan_site">Wohnmobilstellplatz</string>
<string name="menu_apply">Filter anwenden</string>
<string name="menu_save_profile">Als Profil speichern</string>
<string name="menu_reset">Filter zurücksetzen</string>
<string name="no_filters">Keine Filter</string>
<string name="filter_custom">Verändertes Filterprofil</string>
<string name="filter_favorites">Favoriten</string>
<string name="reorder">Reihenfolge ändern</string>
<string name="delete">Löschen</string>
<string name="save_as_profile">Als Profil speichern</string>
<string name="save_profile_enter_name">Geben Sie den Namen des Filterprofils ein:</string>
<string name="save_profile_enter_name">Gib den Namen des Filterprofils ein:</string>
<string name="filterprofiles_empty_state">Du hast keine Filterprofile gespeichert</string>
<string name="welcome_to_evmap">Willkommen bei EVMap</string>
<string name="welcome_1">Finde Ladestationen für Elektroautos in deiner Nähe</string>
@@ -195,6 +197,7 @@
<string name="chargeprice_battery_range_to">bis</string>
<string name="chargeprice_stats">(%1$.0f kWh, ca. %2$s, ⌀ %3$.0f kW)</string>
<string name="chargeprice_vehicle">Fahrzeug</string>
<string name="chargeprice_price_not_available">Preis nicht verfügbar</string>
<string name="edit_on_goingelectric_info">Logge dich zuerst bei GoingElectric.de ein, falls hier nur eine leere Seite erscheint</string>
<string name="close">Schließen</string>
<string name="chargeprice_title">Preise</string>
@@ -270,4 +273,26 @@
<string name="pref_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<string name="about_contributors">Mitwirkende</string>
<string name="about_contributors_text">Dank an alle Mitwirkenden für ihre Beiträge von Code und Übersetzungen für EVMap:</string>
<string name="utilization_prediction">Auslastungsprognose</string>
<string name="prediction_help">Die Prognose basiert auf Faktoren wie Wochentag, Uhrzeit und Nutzung in der Vergangenheit. So kannst du stark ausgelastete Ladesäulen vermeiden. Keine Garantie.</string>
<string name="prediction_time_colon">%s Uhr:</string>
<plurals name="prediction_number_available">
<item quantity="one">%1$d/%2$d verfügbar</item>
<item quantity="other">%1$d/%2$d verfügbar</item>
</plurals>
<string name="pref_prediction_enabled">Auslastungsprognosen anzeigen</string>
<string name="pref_prediction_enabled_summary">für unterstützte Ladestationen\n(momentan nur Schnellader in Deutschland)</string>
<string name="prediction_only">(nur %s)</string>
<string name="prediction_dc_plugs_only">DC-Anschlüsse</string>
<string name="data_source_switched_to">Datenquelle zu %s umgeschaltet</string>
<string name="pref_applink_associate">Unterstützte Links öffnen</string>
<string name="pref_applink_associate_summary">von goingelectric.de und openchargemap.org</string>
<string name="chargeprice_header_my_tariffs">Meine Tarife</string>
<string name="chargeprice_header_other_tariffs">Andere Tarife</string>
<string name="developer_mode_enabled">Entwicklermodus aktiviert</string>
<string name="developer_options">Entwicklereinstellungen</string>
<string name="disable_developer_mode">Entwicklermodus deaktivieren</string>
<string name="developer_mode_disabled">Entwicklermodus deaktiviert</string>
<string name="gps">GPS</string>
<string name="compass">Kompass</string>
</resources>

View File

@@ -271,4 +271,29 @@
<string name="chargeprice_faq_link">https://evmap.vonforst.net/en/chargeprice_faq.html</string>
<string name="about_contributors">Bidragsytere</string>
<string name="about_contributors_text">Takk til alle som har kodet og oversatt EVMap:</string>
<plurals name="prediction_number_available">
<item quantity="one">%1$d/%2$d tilgjengelig</item>
<item quantity="other">%1$d/%2$d tilgjengelig</item>
</plurals>
<string name="prediction_help">Basert på hvilken dag det er, tid på dagen, og tidligere bruk, slik at du kan finne ledige ladere. Ingen garanti dog.</string>
<string name="prediction_time_colon">%s:</string>
<string name="prediction_dc_plugs_only">Likestrømmsstøpsel</string>
<string name="location_error">Klarte ikke å fastsette posisjon. Sjekk systeminnstillingene.</string>
<string name="utilization_prediction">Bruksprognose</string>
<string name="prediction_only">(kun %s)</string>
<string name="pref_prediction_enabled">Vis bruksprognoser</string>
<string name="pref_prediction_enabled_summary">for støttede ladere
\n(foreløpig kun for likestrøm i Tyskland)</string>
<string name="chargeprice_price_not_available">Pris ikke tilgjengelig</string>
<string name="developer_mode_disabled">Utviklermodus avslått</string>
<string name="gps">GPS</string>
<string name="compass">Kompass</string>
<string name="pref_applink_associate">Åpne støttede lenker</string>
<string name="pref_applink_associate_summary">fra goingelectric.de og openchargemap.org</string>
<string name="chargeprice_header_other_tariffs">Andre ladeabonnementer</string>
<string name="disable_developer_mode">Skru av utviklermodus</string>
<string name="chargeprice_header_my_tariffs">Mine ladeabonnementer</string>
<string name="developer_options">Utvikleralternativer</string>
<string name="data_source_switched_to">Datakilde byttet til %s</string>
<string name="developer_mode_enabled">Utviklermodus påslått</string>
</resources>

View File

@@ -2,4 +2,5 @@
<resources>
<color name="background">#121212</color>
<color name="my_tariff_background">#1FFFFFFF</color>
<color name="logo_tint_night">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="bottom_sheet_collapsible">false</bool>
</resources>

View File

@@ -2,4 +2,5 @@
<resources>
<dimen name="map_toolbar_width">500dp</dimen>
<dimen name="layers_fab_top_padding">20dp</dimen>
<dimen name="directions_fab_translationx">-44dp</dimen>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hide_on_scroll_fab_behavior">@null</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="bottom_sheet_collapsible">true</bool>
</resources>

View File

@@ -28,4 +28,5 @@
<color name="my_tariff_background">#1F000000</color>
<color name="background">#FFFFFF</color>
<color name="pager_unselected">#1F000000</color>
<color name="logo_tint_night">@null</color>
</resources>

View File

@@ -11,4 +11,5 @@
<item name="match_parent" type="dimen">-1</item>
<dimen name="map_toolbar_width">@dimen/match_parent</dimen>
<dimen name="layers_fab_top_padding">100dp</dimen>
<dimen name="directions_fab_translationx">0dp</dimen>
</resources>

View File

@@ -8,9 +8,20 @@
<string name="goingelectric_forum_url"><![CDATA[https://www.goingelectric.de/forum/viewtopic.php?f=5&t=56342]]></string>
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
<string name="chargeprice_api_url">https://api.chargeprice.app/v1/</string>
<string name="fronyx_url">https://fronyx.io/</string>
<string name="pref_language_en">English</string>
<string name="pref_language_de">Deutsch</string>
<string name="pref_language_fr">Français</string>
<string name="pref_language_nb_rNO">Norsk Bokmål</string>
<string name="about_contributors_list">Danilo Bargen\nAltonss\nAllan Nordhøy\nLicaon_Kter\npt2121\nnautilusx</string>
<string name="about_contributors_list">
Danilo Bargen\n
Altonss\n
Allan Nordhøy\n
Maximilian Goldschmidt\n
Licaon_Kter\n
pt2121\n
nautilusx
</string>
<string name="hide_on_scroll_fab_behavior">net.vonforst.evmap.ui.HideOnScrollFabBehavior</string>
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
</resources>

View File

@@ -100,6 +100,7 @@
<string name="pref_language">App language</string>
<string name="pref_darkmode">Dark mode</string>
<string name="connection_error">Could not load charging stations</string>
<string name="location_error">Failed to detect location. Please check system settings</string>
<string name="retry">Retry</string>
<string name="filter_open_247">Available 24/7</string>
<string name="filter_barrierfree">Usable without registration</string>
@@ -141,6 +142,7 @@
<string name="category_caravan_site">Caravan site</string>
<string name="menu_apply">Apply filters</string>
<string name="menu_save_profile">Save as profile</string>
<string name="menu_reset">Reset filter settings</string>
<string name="no_filters">No filters</string>
<string name="filter_custom">Modified filter</string>
<string name="filter_favorites">Favorites</string>
@@ -194,6 +196,7 @@
<string name="chargeprice_battery_range_to">to</string>
<string name="chargeprice_stats">(%1$.0f kWh, approx. %2$s, ⌀ %3$.0f kW)</string>
<string name="chargeprice_vehicle">Vehicle</string>
<string name="chargeprice_price_not_available">Price not available</string>
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Utility companies sometimes offer special plans for their customers</string>
<string name="close">Close</string>
<string name="chargeprice_title">Prices</string>
@@ -269,4 +272,26 @@
<string name="pref_provider_osm_mapbox">OpenStreetMap (Mapbox)</string>
<string name="about_contributors">Contributors</string>
<string name="about_contributors_text">Thanks to all contributors for their coding and translation contributions to EVMap:</string>
<string name="utilization_prediction">Utilization prediction</string>
<string name="prediction_help">The prediction is based on factors like day of the week, time of day and past usage, so that you can avoid overcrowded chargers. No guarantee.</string>
<string name="prediction_time_colon">%s:</string>
<plurals name="prediction_number_available">
<item quantity="one">%1$d/%2$d available</item>
<item quantity="other">%1$d/%2$d available</item>
</plurals>
<string name="pref_prediction_enabled">Show utilization predictions</string>
<string name="pref_prediction_enabled_summary">for supported chargers\n(currently only DC in Germany)</string>
<string name="prediction_only">(%s only)</string>
<string name="prediction_dc_plugs_only">DC plugs</string>
<string name="data_source_switched_to">Data source switched to %s</string>
<string name="pref_applink_associate">Open supported links</string>
<string name="pref_applink_associate_summary">from goingelectric.de and openchargemap.org</string>
<string name="chargeprice_header_my_tariffs">My plans</string>
<string name="chargeprice_header_other_tariffs">Other plans</string>
<string name="developer_mode_enabled">Developer mode enabled</string>
<string name="developer_options">Developer options</string>
<string name="disable_developer_mode">Disable developer mode</string>
<string name="developer_mode_disabled">Developer mode disabled</string>
<string name="gps">GPS</string>
<string name="compass">Compass</string>
</resources>

View File

@@ -9,6 +9,12 @@
android:entryValues="@array/pref_data_source_values"
android:defaultValue="goingelectric"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
android:key="prediction_enabled"
android:title="@string/pref_prediction_enabled"
android:defaultValue="true"
android:summary="@string/pref_prediction_enabled_summary" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_map">

View File

@@ -28,4 +28,8 @@
android:summaryOn="@string/pref_navigate_use_maps_on"
android:summaryOff="@string/pref_navigate_use_maps_off"
android:defaultValue="true" />
<Preference
android:key="applink_associate"
android:title="@string/pref_applink_associate"
android:summary="@string/pref_applink_associate_summary" />
</PreferenceScreen>

View File

@@ -0,0 +1,89 @@
package net.vonforst.evmap.api.fronyx
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.okResponse
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Test
import java.net.HttpURLConnection
import java.time.ZoneOffset
import java.time.ZonedDateTime
class FronyxApiTest {
val webServer = MockWebServer()
val fronyx: FronyxApi
init {
webServer.start()
val apikey = ""
fronyx = FronyxApi(
apikey,
webServer.url("/").toString()
)
webServer.dispatcher = object : Dispatcher() {
val notFoundResponse = MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
override fun dispatch(request: RecordedRequest): MockResponse {
val segments = request.requestUrl!!.pathSegments
val urlHead = segments.subList(0, 2).joinToString("/")
when (urlHead) {
"predictions/evse-id" -> {
val id = segments[2]
return okResponse("/fronyx/${id.replace("*", "_")}.json")
}
"predictions/evses" -> {
val ids = request.requestUrl!!.queryParameter("evseIds")!!.split(",")
return okResponse(
"/fronyx/${
ids.map { it.replace("*", "_") }.joinToString(",")
}.json"
)
}
else -> return notFoundResponse
}
}
}
}
@Test
fun apiTestSingle() {
val evseId = "DE*ION*E202102"
runBlocking {
val result = fronyx.getPredictionsForEvseId(evseId)
assertEquals(result.evseId, evseId)
assertEquals(25, result.predictions.size)
assertEquals(
ZonedDateTime.of(2022, 9, 18, 13, 45, 0, 0, ZoneOffset.UTC),
result.predictions[0].timestamp
)
assertEquals(FronyxStatus.AVAILABLE, result.predictions[0].status)
}
}
@Test
fun apiTestMultiple() {
val evseIds = listOf("DE*ION*E202101", "DE*ION*E202102")
runBlocking {
val results = fronyx.getPredictionsForEvseIds(evseIds)
results.forEachIndexed { i, result ->
assertEquals(result.evseId, evseIds[i])
assertEquals(25, result.predictions.size)
assertEquals(
ZonedDateTime.of(2022, 11, 16, 18, 0, 0, 0, ZoneOffset.UTC),
result.predictions[0].timestamp
)
assertEquals(
if (i == 0) FronyxStatus.UNAVAILABLE else FronyxStatus.AVAILABLE,
result.predictions[0].status
)
}
}
}
}

View File

@@ -0,0 +1,21 @@
package net.vonforst.evmap.model
import org.junit.Assert.assertEquals
import org.junit.Test
class ChargersModelTest {
@Test
fun testAddressToString() {
assertEquals("Berlin", Address("Berlin", null, null, null).toString())
assertEquals("12345 Berlin", Address("Berlin", null, "12345", null).toString())
assertEquals(
"Pariser Platz 1, Berlin",
Address("Berlin", null, null, "Pariser Platz 1").toString()
)
assertEquals(
"Pariser Platz 1, 12345 Berlin",
Address("Berlin", null, "12345", "Pariser Platz 1").toString()
)
}
}

View File

@@ -0,0 +1,214 @@
[
{
"evseId": "DE*ION*E202101",
"locationId": "DE-ION-03e2876e-0fd0-4e9d-abc1-d2caa1473947",
"predictions": [
{
"timestamp": "2022-11-16T18:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T18:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T18:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T18:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T22:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T22:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T22:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T22:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T23:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T23:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T23:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T23:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-17T00:00:00.000Z",
"status": "UNAVAILABLE"
}
]
},
{
"evseId": "DE*ION*E202102",
"locationId": "DE-ION-03e2876e-0fd0-4e9d-abc1-d2caa1473947",
"predictions": [
{
"timestamp": "2022-11-16T18:00:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T18:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T18:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T18:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T19:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T20:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-11-16T21:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T21:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T21:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T22:00:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T22:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T22:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T22:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T23:00:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T23:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T23:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-16T23:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-11-17T00:00:00.000Z",
"status": "AVAILABLE"
}
]
}
]

View File

@@ -0,0 +1,105 @@
{
"evseId": "DE*ION*E202102",
"predictions": [
{
"timestamp": "2022-09-18T13:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T14:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T14:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T14:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T14:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T15:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T15:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T15:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T15:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T16:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T16:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T16:30:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T16:45:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T17:00:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T17:15:00.000Z",
"status": "UNAVAILABLE"
},
{
"timestamp": "2022-09-18T17:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T17:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T18:00:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T18:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T18:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T18:45:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T19:00:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T19:15:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T19:30:00.000Z",
"status": "AVAILABLE"
},
{
"timestamp": "2022-09-18T19:45:00.000Z",
"status": "AVAILABLE"
}
]
}

View File

@@ -0,0 +1,40 @@
package net.vonforst.evmap.auto
import org.junit.Assert.assertEquals
import org.junit.Test
class UtilsTest {
@Test
fun testPaginate() {
var (nSingle, nFirst, nOther, nLast) = listOf(6, 5, 4, 5)
for (i in 0..30) {
paginateTest(i, nSingle, nFirst, nOther, nLast)
}
nSingle = 4; nFirst = 4; nOther = 6; nLast = 6
for (i in 0..30) {
paginateTest(i, nSingle, nFirst, nOther, nLast)
}
}
private fun paginateTest(
i: Int,
nSingle: Int,
nFirst: Int,
nOther: Int,
nLast: Int
) {
val list = (0..i).toList()
val paginated = list.paginate(nSingle, nFirst, nOther, nLast)
assertEquals(list, paginated.flatten())
assert(paginated.all { it.isNotEmpty() })
if (paginated.size == 1) {
assert(paginated.first().size <= nSingle)
} else {
assert(paginated.first().size == nFirst)
for (j in 1 until paginated.size - 1) {
assert(paginated[j].size == nOther)
}
assert(paginated.last().size <= nLast)
}
}
}

View File

@@ -3,14 +3,14 @@
buildscript {
ext.kotlin_version = '1.7.10'
ext.about_libs_version = '8.9.4'
ext.nav_version = '2.5.2'
ext.nav_version = '2.5.3'
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"

View File

@@ -26,6 +26,9 @@ be put into the app in the form of a resource file called `apikeys.xml` under
<string name="openchargemap_key" translatable="false">
insert your OpenChargeMap key here
</string>
<string name="fronyx_key" translatable="false">
insert your Fronyx key here
</string>
</resources>
```
@@ -169,3 +172,18 @@ In case you want to pay for access to the full Chargeprice API, check out their
[sales@chargeprice.net](mailto:sales@chargeprice.net).
</details>
Availability data providers
---------------------------
### fronyx
[fronyx](https://fronyx.io/) provides us predictions of charging station availability.
<details>
<summary>How to obtain an API key</summary>
The API is not publically available, contact [fronyx](https://fronyx.io/contact-us/) to get an API
key and documentation.
If you don't want to test this functionality, simply leave the API key blank.
</details>

View File

@@ -0,0 +1 @@
Änderungen in Android Auto aus 1.3.12 rückgängig gemacht, da diese für Abstürze sorgten.

View File

@@ -0,0 +1,11 @@
Neue Features:
- Auslastungsprognose (nur für DC-Ladestationen in Deutschland, powered by fronyx)
Verbesserungen:
- Layout für die Karten- und Detailansicht für große Tablets angepasst
- Android Auto: Mehr Details im Preisvergleich, Suchbutton auf dem Hauptbildschirm, Android Auto: Emoji durch Icons ersetzt
Fehler behoben:
- Inkonsistente Anzeige beim Laden der Details
- Preisvergleich: Langsames Wechseln der Anschlüsse
- Android Auto: Korrektur Auswahl des Ladepunkts für Preisvergleich

View File

@@ -0,0 +1,9 @@
Verbesserungen:
- Preisvergleich: Auch Tarife mit fehlenden Preisdaten anzeigen
- Links von openchargemap.org können in EVMap geöffnet werden
- Android Auto: Ladegeschwindigkeit bei der Detailansicht optimiert
- Android Auto: Mehrseitige Ansichten für Filter und Filterprofile (falls nötig)
Fehler behoben:
- diverse kleine Darstellungsfehler behoben
- Abstürze behoben

View File

@@ -0,0 +1,2 @@
Fehler behoben:
- Links zur Datenquelle werden im Browser geöffnet auch wenn EVMap als Standard für Links von GoingElectric/OpenChargeMap gesetzt ist

View File

@@ -0,0 +1,8 @@
Verbesserungen:
- Laden der Verfügbarkeitsprognosen beschleunigt
- Android Auto: "Meine Tarife" im Preisvergleich hervorheben
Fehler behoben:
- Android Automotive: Aktualisieren-Button fehlte
- Darstellungsfehler nach dem Scrollen der Detailansicht behoben
- Abstürze behoben

View File

@@ -0,0 +1,9 @@
Verbesserungen:
- Laden der Verfügbarkeitsprognosen beschleunigt
- Android Auto: "Meine Tarife" im Preisvergleich hervorheben
Fehler behoben:
- Android Automotive: Aktualisieren-Button fehlte
- Android Auto: Klick auf Suchergebnis funktioniert manchmal nicht
- Darstellungsfehler nach dem Scrollen der Detailansicht behoben
- Abstürze behoben

View File

@@ -0,0 +1,5 @@
Verbesserungen:
- Android Auto: Suchbutton während der Fahrt freigeschaltet (ggf. ohne Tastatur)
Fehler behoben:
- Abstürze behoben

View File

@@ -0,0 +1,7 @@
Verbesserungen:
- Neuer Knopf zum Zurücksetzen der Filtereinstellungen
- Filtermenü mit neuen Icons
- Übersetzungen aktualisiert
Fehler behoben:
- Abstürze behoben

View File

@@ -0,0 +1 @@
Reverted Android Auto changes in 1.3.13, which caused crashes

View File

@@ -0,0 +1,11 @@
New features:
- Utilization prediction (only for DC chargers in Germany, powered by fronyx)
Improvements:
- Map and detail view adapted for large tablets
- Android Auto: More details on price comparison screen, Search button is now located on main screen, Replaced emojis with proper icons
Bugfixes:
- Inconsistent display while loading charger details
- Price comparison: Slowdown while switching connectors
- Android Auto: Fixed selecting highest-power chargepoint for price comparison

View File

@@ -0,0 +1,9 @@
Improvements:
- Price comparison: Also show plans with unknown pricing
- Links from openchargemap.org can be opened with EVMap
- Android Auto: Improved loading speed for detail view
- Android Auto: Multi-page views for filters and filter profiles (if necessary)
Bugfixes:
- fixed multiple minor display bugs
- fixed crashes

View File

@@ -0,0 +1,2 @@
Bugfixes:
- Open links to data source in browser even if EVMap is set to open GoingElectric/OpenChargeMap links by default

View File

@@ -0,0 +1,8 @@
Improvements:
- Faster loading of availability prediction
- Android Auto: highlight "my plans" in price comparison
Bugfixes:
- Android Automotive: refresh button was missing
- fixed visual bug after scrolling detail view
- fixed crashes

View File

@@ -0,0 +1,9 @@
Improvements:
- Faster loading of availability prediction
- Android Auto: highlight "my plans" in price comparison
Bugfixes:
- Android Automotive: refresh button was missing
- Android Auto: Clicking search result sometimes not working
- fixed visual bug after scrolling detail view
- fixed crashes

View File

@@ -0,0 +1,5 @@
Improvements:
- Android Auto: Search button available while driving (possibly without keyboard)
Bugs fixed:
- Fixed crashes

View File

@@ -0,0 +1,7 @@
Improvements:
- New button to reset filter setting
- Filter menu with new icons
- Updated translations
Bugfixes:
- Fixed crashes