Compare commits

...

342 Commits
0.4.3 ... 1.1.2

Author SHA1 Message Date
johan12345
4f6f09dc83 Release 1.1.2 2021-11-14 18:17:39 +01:00
johan12345
7f6d0c1391 update dependencies 2021-11-14 17:59:41 +01:00
johan12345
96b60d0f49 GoingElectric API: do not show "paid parking" / "paid charging"
because that may also mean that no information is available
see #13
2021-11-14 17:33:36 +01:00
johan12345
2824f0b5c3 handle saved state for MapViewModel 2021-11-14 17:19:11 +01:00
johan12345
af0921ed20 implement a93bacd9b3 for Android Auto 2021-11-14 16:58:44 +01:00
johan12345
a5b55479cb Detail view: show opening hours description also if open 24/7 2021-11-14 16:40:06 +01:00
johan12345
a93bacd9b3 Chargeprice: show provider-exclusive plans when included in "my plans"
fixes #147
2021-11-14 16:26:43 +01:00
johan12345
9d7278e0e2 AvailabilityDetector: support missing household plugs in NewMotion data
fixes #146
2021-11-14 15:58:08 +01:00
johan12345
f6d9c615a0 AvailabilityDetectorTest: use proper socket type constants 2021-11-14 15:40:13 +01:00
johan12345
a8ee3f5b7d Change semantics of opening hours in model
to fix incompatibility with Room that caused a NullPointerException
2021-11-14 15:27:49 +01:00
johan12345
826b4f89f1 fix crash in light mode
introduced in 5d7d881729
2021-11-06 22:51:16 +01:00
johan12345
5675d065e3 update Car App Library to 1.1.0-rc01 2021-11-06 22:34:42 +01:00
johan12345
3e3531551d add link to Chargeprice FAQ page 2021-11-06 22:30:31 +01:00
johan12345
5d7d881729 Chargeprice: add branding 2021-11-06 21:29:17 +01:00
johan12345
23c73e3d7e Android Auto: add error message when Chargeprice data fails to load 2021-11-06 20:02:02 +01:00
johan12345
7835aa8d78 initialize Google map with correct locale 2021-11-04 21:47:33 +01:00
johan12345
f06b712090 disable NullSafeMutableLiveData lint error
seems to give false positives
2021-11-01 19:22:34 +01:00
johan12345
317695954d remove unneeded maven repositories 2021-11-01 15:27:56 +01:00
johan12345
24cfd1c10b upgrade dependencies 2021-11-01 15:16:21 +01:00
johan12345
775faa2f55 update AboutLibraries 2021-11-01 15:00:45 +01:00
johan12345
08bd2bdf5a update build tools 2021-11-01 15:00:30 +01:00
johan12345
90254915e3 Release 1.1.1 2021-11-01 13:10:37 +01:00
johan12345
b7f56ecff4 fix detection of imperial units when locale is "unspecified English" 2021-11-01 13:03:50 +01:00
johan12345
fa3910d3c8 avoid NPE when country == null 2021-11-01 12:47:05 +01:00
johan12345
4500c55560 Android Auto: throttle updates of distances
to mitigate bug in AA https://issuetracker.google.com/issues/204692002
2021-11-01 12:39:59 +01:00
johan12345
a493e1a548 Android Auto MapScreen: show distances in correct units 2021-11-01 12:03:27 +01:00
johan12345
ddaab42e45 update filteredConnectors before chargepoints
fixes incorrect marker colors
2021-11-01 10:37:46 +01:00
johan12345
9f50341ab7 use latest Google Maps renderer 2021-11-01 10:26:30 +01:00
johan12345
9966b44a76 Release 1.1.0 2021-10-17 21:29:53 +02:00
johan12345
d44b2206d2 update build status badge 2021-10-17 15:49:56 +02:00
johan12345
f61082f491 fix incorrect formatting in api_keys.md 2021-10-17 15:27:01 +02:00
johan12345
f58d96c939 fix links in README 2021-10-17 15:26:08 +02:00
Johan von Forstner
29aedfa3d9 Merge pull request #140 from johan12345/contributing-docs
Improve docs for first-time contributors
2021-10-17 15:25:15 +02:00
johan12345
8331f92f10 Improve docs for first-time contributors
adds two docs pages with info on API keys and Android Auto testing.
fixes #112
2021-10-17 15:24:50 +02:00
johan12345
123680d3e8 replace Mapbox android-plugin-places with just the Java SDK
(the UI has been replaced by our own one in #120)
2021-10-17 12:43:16 +02:00
johan12345
0f6b45d745 upgrade navigation component to 2.4.0-alpha10
(necessary for Android 12 compatibility of deep links)
2021-10-17 12:11:28 +02:00
Johan von Forstner
69faa94f18 Merge pull request #139 from pt2121/pt/safeArgs
refactor to use Android Navigation Component's SafeArgs
2021-10-17 12:10:16 +02:00
prat t
70805b7960 refactor to use Navigation Component's SafeArgs 2021-10-16 15:16:56 -07:00
johan12345
56453b0658 remove obsolete TODO 2021-10-16 15:51:36 +02:00
johan12345
975d95e37e refactor app settings into separate sub-screens
for better overview
2021-10-16 15:49:00 +02:00
johan12345
ba34cd016a fix static splashscreen icon size
after upgrade to core-splashscreen 1.0.0-alpha02 in e2bcf8d1
2021-10-16 12:36:29 +02:00
johan12345
590b16aa49 make debug build distinguishable from release build
with grayscale app icon + different label
fixes #113
2021-10-16 12:25:26 +02:00
johan12345
5fe8d0cab4 replace Google Maps v3 beta with Play Services version 17.0.1
fixes #124
fixes #30
2021-10-16 12:16:27 +02:00
johan12345
9d7b181410 remove focus from search when selecting a charger 2021-10-10 17:55:16 +02:00
johan12345
128532aac6 open autocomplete list more quickly 2021-10-10 17:51:54 +02:00
johan12345
486854f56c Android Auto: use slightly darker color for >100 kW chargers for better contrast 2021-10-09 19:16:21 +02:00
johan12345
1e30db5cd1 GoingElectricApi: map plug types correctly to names 2021-10-09 13:55:25 +02:00
johan12345
aad386ab04 MultiSelectDialog: put common choices on top even when selected 2021-10-09 13:44:17 +02:00
johan12345
e2bcf8d1cd upgrade dependencies 2021-10-08 22:02:04 +02:00
Johan von Forstner
f56fad1282 Merge pull request #134 from johan12345/filter-by-favorites
add possibility to show only the favorites on a map
2021-10-08 21:51:46 +02:00
johan12345
adb4d938cc add possibility to show only the favorites on a map (fixes #119) 2021-10-08 21:51:13 +02:00
Johan von Forstner
b773f65912 Update screenshot URLs 2021-10-06 08:39:13 +02:00
johan12345
de335b18d8 PlaceAutocompleteAdapter: do not modify resultList on background thread 2021-10-05 21:43:47 +02:00
johan12345
6c8380b8ce revisit Android Auto location service connection
to possibly fix IllegalArgumentException: Service not registered
based on https://stackoverflow.com/questions/22079909/android-java-lang-illegalargumentexception-service-not-registered
2021-10-05 21:12:03 +02:00
Johan von Forstner
81afdca19d Merge pull request #130 from johan12345/highlight_favorites
highlight favorites on map
2021-10-03 13:33:12 +02:00
johan12345
14e03ba6dd highlight favorites on map
fixes #118
2021-10-03 13:31:58 +02:00
Johan von Forstner
abe12b45c3 disable git LFS for screenshots
(not supported on F-Droid
2021-10-03 12:55:11 +02:00
johan12345
23387ae371 Release 1.0.0 2021-10-02 14:08:44 +02:00
johan12345
25f466b6d7 Merge branch 'place-search-recents' 2021-10-02 13:49:25 +02:00
johan12345
6692b21bf9 add non-dark Mapbox logo 2021-10-02 13:48:50 +02:00
johan12345
5959fe8be4 limit number of autocomplete results 2021-10-02 13:40:22 +02:00
johan12345
00f4c13fcc add button to delete search history 2021-10-02 13:33:51 +02:00
johan12345
47054d470b replace deprecated method getAdapterPosition 2021-10-02 13:23:30 +02:00
Johan von Forstner
d10192cae1 save recent place search results
fixes #128
2021-10-02 13:19:33 +02:00
johan12345
e1b90955c3 handle back button correctly
fixes #115
2021-10-02 13:18:27 +02:00
johan12345
d249bf47c7 improve title of filter profiles editing 2021-10-02 13:15:58 +02:00
johan12345
738dcd5f8d do not allow blank names for filter profiles 2021-10-02 13:11:05 +02:00
johan12345
ad4f32ec32 fix missing filter name in title of FilterFragment 2021-10-02 12:47:22 +02:00
Johan von Forstner
4d03107ae7 add links to translated FAQ and privacy policy
#110
2021-09-26 16:17:44 +02:00
Johan von Forstner
0e93e310bf MultiSelectDialog: move selected entries to the top
fixes #126
2021-09-26 16:17:44 +02:00
Johan von Forstner
6cb8940696 Catch IllegalArgumentException in navigation library 2021-09-26 14:47:55 +02:00
johan12345
dad30eb51e Release 1.0.0-beta04 2021-09-19 19:37:52 +02:00
johan12345
abf6a2b933 upgrade some dependencies 2021-09-19 19:28:26 +02:00
johan12345
2c5685d918 Android Auto: use smartphone location even with car API level 3
if car hardware location is not available
2021-09-19 19:25:59 +02:00
johan12345
b61e57b022 Android Auto: fix crash when filter profile has no name 2021-09-19 19:25:59 +02:00
Johan von Forstner
e6428cc8db Merge pull request #125 from johan12345/android-12
Update to Android 12 targetSdkVersion and implement corresponding changes
2021-09-12 21:39:39 +02:00
johan12345
6302006a35 Android 12 compat: implement new SplashScreen API
with animated icon
update onboarding to avoid showing animation twice
2021-09-12 21:21:17 +02:00
johan12345
ab93577a98 Android 12 compat: support for approximate location permission (#123) 2021-09-12 21:21:17 +02:00
johan12345
98b695ed4b Android 12 compat: set exported attribute explicitly (#123)
for services and activities in Manifest
2021-09-12 18:48:15 +02:00
johan12345
ed8cb50b08 increase targetSdk to Android 12 (API 31) (#123) 2021-09-12 18:48:14 +02:00
johan12345
88d89c2760 fix crash in FavoritesFragment.onDestroy
apparently it can be called before onCreate?
2021-09-12 18:00:07 +02:00
johan12345
80c25cb416 build.gradle: add possibility to pass encrypted Mapbox API Key 2021-09-12 12:23:25 +02:00
johan12345
81c8e54dd2 Release 1.0.0-beta03 2021-09-12 11:49:09 +02:00
johan12345
8c01ee1581 shorten error message on Android Auto 2021-09-12 11:48:28 +02:00
johan12345
e8db5acfbf Check if Android Auto Version >= 6.7 before using Car API Level 3 2021-09-12 11:46:15 +02:00
johan12345
f6bb3c03ba Release 1.0.0-beta02
(just to fix a crash caused by Lottie)
2021-09-11 22:29:20 +02:00
johan12345
134f3856b9 upgrade Lottie 2021-09-11 22:23:30 +02:00
johan12345
4974cc6d83 Release 1.0.0-beta01 2021-09-11 11:53:13 +02:00
Johan von Forstner
edd072b83a Merge pull request #101 from johan12345/car-app-library-1.1.0
Update Android Auto to Car App Library 1.1.0, Adds vehicle data and Chargeprice integration
2021-09-11 11:43:27 +02:00
Johan von Forstner
35ddda5bfe Update screenshots in README 2021-09-11 11:16:09 +02:00
johan12345
8b241e3f6f new screenshots
- English and German
- Mapbox and Google Maps
- + Android Auto
2021-09-11 11:09:08 +02:00
johan12345
b3c5fe788d suppress lint error 2021-09-11 09:54:30 +02:00
johan12345
6fd737f6e9 Android Auto: Disable Chargeprice in unsupported countries
see also: cf6c6628, #117
2021-09-11 09:54:30 +02:00
johan12345
08cd4eb849 Android Auto: do not update the map when location changes
to avoid running into template restrictions
2021-09-11 09:54:30 +02:00
johan12345
ff75594b37 get CarHardwareManager lazily 2021-09-11 09:54:30 +02:00
johan12345
2576bc4854 upgrade compileSdk to Android 12
required for new car app library
2021-09-11 09:54:30 +02:00
johan12345
b2c29b647b upgrade car app library to 1.1.0-beta01 2021-09-11 09:54:30 +02:00
johan12345
2167a63321 only use ConstraintManager if car API level >= 2
refs a562ee6c
2021-09-11 09:54:30 +02:00
johan12345
fb0a2cfa1c internal test release 2021-09-11 09:54:30 +02:00
johan12345
07be77c573 ChargepriceScreen: fix showing error messages 2021-09-11 09:54:30 +02:00
johan12345
ae0a84db4c VehicleDataScreen: setup listeners with lifecycle events 2021-09-11 09:54:30 +02:00
johan12345
dc5ffb148d Chargeprice: check car API level 2021-09-11 09:54:30 +02:00
johan12345
066b7c085e add link to Chargeprice website 2021-09-11 09:54:30 +02:00
johan12345
4ae16df064 add Chargeprice icon 2021-09-11 09:54:30 +02:00
johan12345
17a40127e6 add Chargeprice to Android Auto
fixes #80
2021-09-11 09:54:30 +02:00
johan12345
31ad748796 use car hardware location data if available 2021-09-11 09:54:30 +02:00
johan12345
fe4db38798 show vehicle data screen only if API level available 2021-09-11 09:54:30 +02:00
johan12345
6c2243078b Vehicle data screen: Add speed and range + gauge icons 2021-09-11 09:54:30 +02:00
johan12345
71f1ee8d7b make VehicleDataScreen request permissions and work correctly 2021-09-11 09:54:30 +02:00
johan12345
ab0c37cb82 make PermissionScreen reusable 2021-09-11 09:54:30 +02:00
Johan von Forstner
65189cd798 Android Auto: create a VehicleDataScreen showing state of charge 2021-09-11 09:54:30 +02:00
Johan von Forstner
630178bfcf Update car app library to 1.1.0-alpha02 2021-09-11 09:54:30 +02:00
Johan von Forstner
bcee975124 remove now unneeded @ExperimentalCarApi annotations 2021-09-11 09:54:30 +02:00
Johan von Forstner
04fc17d73c increase image size corresponding to updated Android Auto docs 2021-09-11 09:54:30 +02:00
Johan von Forstner
139c02ef70 use ConstraintManager to dynamically get maximum number of items to be shown on Android Auto list 2021-09-11 09:54:30 +02:00
Johan von Forstner
88a8520f27 use CarAppService.requestPermission() instead of custom PermissionActivity 2021-09-11 09:54:30 +02:00
johan12345
4f3157a0ac update Car app library to 1.1.0-alpha1 2021-09-11 09:54:30 +02:00
johan12345
17d57729b3 remove Android Auto screenshots from F-Droid 2021-09-11 09:49:40 +02:00
johan12345
1f3df2e0bf update F-Droid description, removing features that are not available in the F-Droid version
(Google Maps data, Android Auto)
2021-09-11 09:49:09 +02:00
johan12345
e2e95ce85d fix NPE in AvailabilityDetector 2021-09-06 20:34:20 +02:00
johan12345
d79b554dcc remove unused nav graph destination 2021-09-06 20:18:25 +02:00
johan12345
98e91ea3db FavoritesFramgent: create locationClient in onCreate, not onCreateView 2021-09-05 17:02:45 +02:00
johan12345
b8c8245978 Android Auto: avoid unnecessary location updates 2021-09-05 16:47:22 +02:00
johan12345
fd1f05888a fix IndexOutOfBoundsException 2021-09-05 15:01:46 +02:00
johan12345
2e4167689d new 0.9.1 release 2021-09-01 19:35:09 +02:00
johan12345
8a2ad55dd6 Mapbox Autocomplete: set language 2021-09-01 19:33:31 +02:00
johan12345
44ce0cfaea Release 0.9.1 2021-09-01 19:23:27 +02:00
johan12345
70f964549e limit autocomplete entries to a single line 2021-09-01 19:14:23 +02:00
johan12345
c045eed41a Mapbox Autocomplete: handle cases where house number comes in front of address 2021-09-01 19:11:12 +02:00
johan12345
3ded108c3c fix exiting app intro after 7eeb10fa 2021-09-01 18:58:38 +02:00
johan12345
b3eb1e31e8 app intro data source selection: add missing fillViewport to ScrollView 2021-09-01 18:52:52 +02:00
johan12345
7eeb10faca app intro: add page about Android Auto support
fixes #114
2021-09-01 18:50:58 +02:00
johan12345
4208e1a4b5 rename some strings 2021-09-01 18:24:54 +02:00
johan12345
54004f14b5 fix missing SharedPreferences.apply() 2021-09-01 18:22:04 +02:00
johan12345
8eabff4888 fix crash on rotate in various fragments
caused by access to appBarConfiguration before it is initialized
fixes #121
2021-09-01 18:20:36 +02:00
johan12345
d5b5337aeb donation screen: limit width of description so that it does not overlap with the price 2021-08-29 16:55:36 +02:00
johan12345
913d8a00cf add dialog with info about open source project and donations
shown after a few launches of the app
#114
2021-08-29 16:52:44 +02:00
Johan von Forstner
fc5003cd31 Merge pull request #120 from johan12345/new-autocomplete-ui
New place search UI supporting both Mapbox and Google Places
2021-08-29 00:08:02 +02:00
johan12345
ff96e49ead use TooltipCompat (fixes crash on Android 7 and earlier) 2021-08-29 00:05:10 +02:00
johan12345
36bd74e091 adjust position of search bar 2021-08-28 23:49:32 +02:00
johan12345
3d0dc16f49 Google Places: fix API quota limit detection 2021-08-28 23:42:11 +02:00
johan12345
41b374350b display distance in autocomplete results 2021-08-28 23:31:00 +02:00
johan12345
fc72044b82 delay Mapbox autocomplete requests 2021-08-28 22:55:57 +02:00
johan12345
e96fcd4a88 make data provider for place search selectable in settings 2021-08-28 22:55:57 +02:00
johan12345
36f34bde1e build new custom autocomplete UI supporting both Google Maps and Mapbox 2021-08-28 22:55:57 +02:00
johan12345
624c5d8f92 Chargeprice: tariff name and provider name comparison should be case-independent 2021-08-26 09:32:32 +02:00
johan12345
7fcb187dda my tariffs preference: add summary 2021-08-26 09:29:13 +02:00
johan12345
7188a2aa64 add ACRA for crash reporting 2021-08-26 09:24:46 +02:00
johan12345
cf6c662832 Disable Chargeprice button for unsupported countries (fixes #117) 2021-08-24 08:59:55 +02:00
johan12345
4ceef7997d "About EVMap" screen: explain what "FAQ" means 2021-08-23 14:51:20 +02:00
johan12345
3f79bdd125 App intro: add info where to find the colors later 2021-08-23 14:48:48 +02:00
Johan von Forstner
fb279f90c5 Add sponsoring info 2021-08-21 16:41:53 +02:00
johan12345
6f35ced260 fix Chargeprice website link for OpenChargeMap 2021-08-15 18:31:44 +02:00
Johan von Forstner
c967bab524 Merge pull request #116 from johan12345/adaptive-layout
Adaptive layout for intro and other things
2021-08-15 12:25:47 +02:00
johan12345
6bf80e2b49 adjust detail view peekHeight for nonstandard font sizes 2021-08-14 17:54:58 +02:00
johan12345
d97cb4b9fb put data source selection into ScrollView 2021-08-14 17:33:34 +02:00
johan12345
17eaeb99da decrease intro animation size if necessary 2021-08-14 16:39:56 +02:00
johan12345
beebbe1c1b avoid animations overlapping text in intro 2021-08-14 16:35:24 +02:00
Johan von Forstner
0a2bbd5fb4 Fix typo in README 2021-08-10 19:42:43 +02:00
johan12345
7f1f4b67a1 Release 0.9.0 2021-08-09 19:07:55 +02:00
Johan von Forstner
d5e29a5112 Android Auto: implement filter profiles
fixes #72
2021-08-09 19:00:20 +02:00
johan12345
77f478c9e0 fix loading donation products 2021-08-09 18:37:18 +02:00
johan12345
1008a2c2cd update Google donation percentage 2021-08-09 18:29:29 +02:00
Johan von Forstner
2219e2fe27 Show current filter profile title in filter edit view 2021-08-08 16:42:35 +02:00
Johan von Forstner
8ce145a9af add dataSource column to ChargeLocation table 2021-08-08 16:24:18 +02:00
Johan von Forstner
b799dae28b add DB migration for GE plug types
fixes problem with loading the availability of favorites
2021-08-08 16:01:05 +02:00
Johan von Forstner
07a482a6b6 fix lint error 2021-08-07 20:53:34 +02:00
Johan von Forstner
4f1253b201 upgrade Travis CI 2021-08-07 20:32:13 +02:00
Johan von Forstner
8bc4a7ae40 Mapbox Autocomplete: use proximity and locale 2021-08-07 20:22:38 +02:00
Johan von Forstner
d686becfe4 update Gradle and Android plugin 2021-08-07 19:54:34 +02:00
Johan von Forstner
a686c51b32 always use Mapbox autocomplete
to avoid Google API costs
fixes #105
2021-08-07 19:47:06 +02:00
Johan von Forstner
382ead9e08 update app description 2021-07-31 20:26:18 +02:00
Johan von Forstner
2da7ea4c05 Release 0.8.4 2021-07-31 20:09:05 +02:00
Johan von Forstner
20c4274c55 Chargeprice: prevent same value for start and end state of charge 2021-07-31 20:05:41 +02:00
Johan von Forstner
748212189f add missing import 2021-07-29 17:47:25 +02:00
Johan von Forstner
d86a49beb7 move Android Auto screen classes to separate files 2021-07-29 17:13:33 +02:00
Johan von Forstner
f8b1a20d1a OpenChargeMap: Comments are optional 2021-07-29 14:31:00 +02:00
Johan von Forstner
14edb6f0cd release 0.8.3 2021-07-27 22:11:52 +02:00
Johan von Forstner
7726088f91 update AnyMaps 2021-07-27 22:09:18 +02:00
Johan von Forstner
cbc7c5a6d8 MapViewModel: cancel loading charger details when another charger is selected 2021-07-25 19:23:20 +02:00
Johan von Forstner
d510d81914 SettingsFragment: move appBarConfiguration to onResume to fix crash when changing dark mode setting 2021-07-25 19:14:23 +02:00
Johan von Forstner
9f5abd6c91 apparently we need @ExperimentalCarApi all classes that create a MapScreen as well 2021-07-22 13:57:31 +02:00
Johan von Forstner
966f62ac3d move @ExperimentalCarApi annotation to the whole MapScreen class 2021-07-22 13:03:40 +02:00
Johan von Forstner
91caf40bdb Android Auto: show city next to charger name
if there is enough room, the name does not already contain the city, and not all chargers on the list are in the same city
fixes #102
2021-07-22 12:41:55 +02:00
Johan von Forstner
72c0293365 update AnyMaps
New version uses Mapbox's legacy Marker API instead of the annotation plugin. This might be a fix for #91
2021-07-22 11:45:18 +02:00
johan12345
ca9dc9629f fix a coroutine crash when no internet available 2021-07-20 20:18:08 +02:00
johan12345
438e529257 fix crash in Android Auto 2021-07-20 19:43:37 +02:00
johan12345
5f69123d89 Release 0.8.2 2021-07-18 20:22:02 +02:00
johan12345
cf421b52a8 catch IOExceptions 2021-07-18 20:16:14 +02:00
johan12345
1b049d35b8 fix IndexOutOfBoundsException 2021-07-18 20:09:34 +02:00
johan12345
f6690a3566 add swipe-to-delete to favorites (fixes #75) 2021-07-18 20:02:05 +02:00
johan12345
cc97020216 adjust "report new charger" button in menu to use OpenChargeMap if chosen 2021-07-18 19:20:33 +02:00
johan12345
0e1e3ba46e use StfalconImageViewer for gallery fullscreen view
fixes #61
2021-07-17 22:22:39 +02:00
Johan von Forstner
657c209827 README.md: fix indentation 2021-07-16 22:56:38 +02:00
johan12345
6ec44bb526 fix filtering of charger status by selected connectors (fixes #100) 2021-07-16 22:40:57 +02:00
johan12345
0943505d90 Chargeprice: show charging duration (fixes #99) 2021-07-16 22:30:02 +02:00
johan12345
f155f7615f Release 0.8.1 2021-07-14 23:25:02 +02:00
johan12345
e8850575f2 avoid another Chargeprice crash 2021-07-14 23:20:29 +02:00
johan12345
d1c4d0a621 fix crash in Chargeprice window for certain chargers 2021-07-14 23:16:40 +02:00
johan12345
ecf27abdc5 remove unnecessary conversion of filter values 2021-07-14 23:05:17 +02:00
johan12345
5f5142baa6 fix bug with connectors filter in GoingElectricApi 2021-07-14 23:00:06 +02:00
johan12345
fa53a9fc5a cleaner implementation of equals check on FilterValues 2021-07-14 23:00:06 +02:00
johan12345
9a0a7b4e5f add SVG source file for Type1 connector 2021-07-14 23:00:06 +02:00
johan12345
1a43703db5 speed up database operations when saving filter values 2021-07-14 23:00:06 +02:00
Johan von Forstner
459589c51f Merge pull request #98 from johan12345/dependabot/bundler/addressable-2.8.0
Bump addressable from 2.7.0 to 2.8.0
2021-07-14 19:12:27 +02:00
dependabot[bot]
9393fe7380 Bump addressable from 2.7.0 to 2.8.0
Bumps [addressable](https://github.com/sporkmonger/addressable) from 2.7.0 to 2.8.0.
- [Release notes](https://github.com/sporkmonger/addressable/releases)
- [Changelog](https://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sporkmonger/addressable/compare/addressable-2.7.0...addressable-2.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-07-13 15:00:17 +00:00
Johan von Forstner
f62bd1c3c4 README: Update description with OCM support 2021-07-11 23:39:10 +02:00
johan12345
27ff992d97 Release 0.8.0 2021-07-11 21:54:28 +02:00
johan12345
cb4b8a7d5f Chargeprice: use NestedScrollView 2021-07-11 21:46:17 +02:00
johan12345
671424b202 Chargeprice: allow choosing multiple vehicles (fixes #95) 2021-07-11 21:46:16 +02:00
johan12345
ce1a7da1f5 favorites list: only show distance if known 2021-07-11 15:22:18 +02:00
johan12345
236aefa34d fix (de)serialization of favorites 2021-07-11 15:20:34 +02:00
Johan von Forstner
d179490891 Merge pull request #96 from johan12345/openchargemap
Support for OpenChargeMap as a data source
2021-07-11 14:44:42 +02:00
johan12345
91e4cb3f14 adapt GoingElectric-specific strings 2021-07-11 14:34:53 +02:00
johan12345
37f02f52e9 OpenChargeMap: support fault reports 2021-07-11 13:55:08 +02:00
johan12345
01f1ffb646 OpenChargeMap: fix local filters when quantity not specified 2021-07-11 13:33:26 +02:00
johan12345
131c93c86b OpenChargeMap: use non-verbose API responses 2021-07-11 13:08:32 +02:00
johan12345
4dd1a648ce merge database migrations into one 2021-07-11 12:50:13 +02:00
johan12345
91df749bc4 add icon for CCS Type 1 connector (#6) 2021-07-11 12:44:01 +02:00
johan12345
20b04e55fb rename GoingElectric-specific database tables 2021-07-11 12:28:08 +02:00
johan12345
a94ad9e8c2 Separate filter values & filter profiles by data source 2021-07-11 12:24:17 +02:00
johan12345
807ff50612 implement getChargepointRadius for Android Auto 2021-07-10 18:27:28 +02:00
johan12345
d46ff39c2b OpenChargeMap: sane default for quantity (0 -> 1) 2021-07-10 18:14:07 +02:00
johan12345
199de04562 update CarAppService for new API structure 2021-07-09 18:04:46 +02:00
johan12345
f2a18b7677 simplify ViewModel creation 2021-07-09 17:40:28 +02:00
johan12345
aab816db32 add landscape layout for onboarding 2021-07-09 17:37:04 +02:00
johan12345
66ad6b9931 OnboardingFragment: fix crash on rotate 2021-07-09 17:09:04 +02:00
johan12345
beeefb2be1 add some more animations to the new onboarding 2021-07-09 17:01:51 +02:00
johan12345
110c418d01 update unit tests after API module restructuring 2021-07-09 16:14:09 +02:00
johan12345
1296e66902 add OpenChargeMap API key to Travis CI 2021-07-09 00:34:00 +02:00
johan12345
31d969e071 create a new onboarding flow including the API selection 2021-07-09 00:11:38 +02:00
johan12345
32681f6ea8 add detailed dialog for data source selection 2021-07-07 21:51:55 +02:00
johan12345
d77f67aa91 OpenChargeMap: increase maxresults 2021-07-07 21:51:55 +02:00
johan12345
be071cfa3a OpenChargeMap: implement filters 2021-07-07 21:51:55 +02:00
johan12345
e098c70684 OpenChargeMap: also load user comments (not yet implemented) 2021-07-07 21:51:54 +02:00
johan12345
91509f5846 fix chargeprice crash when no car is selected 2021-07-07 21:51:54 +02:00
johan12345
454cc44793 add setting to switch between GoingElectric and OpenChargeMap data sources 2021-07-07 21:51:54 +02:00
johan12345
1baf94d788 fix vehicle compatible connectors in Chargeprice 2021-07-07 21:51:54 +02:00
johan12345
b0d9317f73 OpenChargeMap: add Chargeprice support 2021-07-07 21:51:54 +02:00
johan12345
9b80f03993 OpenChargeMap: implement charger photos 2021-07-07 21:51:54 +02:00
johan12345
af0fd8bf69 fix passing data to ChargepriceFragment
(not yet implemented for OpenChargeMap)
2021-07-07 21:51:54 +02:00
johan12345
f76b19e818 store OCM reference data locally 2021-07-07 21:51:54 +02:00
johan12345
02b717c612 OpenChargeMap: numPoints can be null 2021-07-07 21:51:54 +02:00
johan12345
e29d40bca2 Implement operators and licenses for OpenChargeMap data 2021-07-07 21:51:54 +02:00
johan12345
7f8403cfb4 OpenChargeMapApi: implement conversion of plug types 2021-07-07 21:51:54 +02:00
johan12345
d5168f12c6 continue working on OCM API, proof of concept works 2021-07-07 21:51:54 +02:00
johan12345
9b94bbf098 Encapsulate of GoingElectric API to create an interface the OpenChargeMap API can implement 2021-07-07 21:51:54 +02:00
johan12345
34c83c2253 basic implementation of OpenChargeMap API (#81) 2021-07-07 21:51:54 +02:00
johan12345
16cfa3b37b explicitly disconnect from location service 2021-07-03 18:44:04 +02:00
johan12345
498dc63f91 Release 0.7.3 2021-07-03 13:15:15 +02:00
johan12345
c48330dc35 add button to explicitly close the layers menu (#50) 2021-07-03 13:11:00 +02:00
johan12345
ca8abd9b12 fix #92 by using forked version of Mapbox Places Plugin 2021-07-03 12:41:22 +02:00
johan12345
72b2b34af3 disable myTariffsPreference until tariffs are loaded 2021-07-03 11:32:37 +02:00
johan12345
6a7b7a7d39 avoid passing empty string to Android Auto, which caused crash 2021-07-03 11:31:08 +02:00
johan12345
c1af372a06 avoid crash when no browser is installed 2021-07-03 11:23:59 +02:00
johan12345
7946663299 be a bit smarter about bounding boxes for various types of shared locations 2021-06-10 21:05:48 +02:00
johan12345
232aecfe3b add support for another format of geo intent (fixes #90) 2021-06-10 20:41:45 +02:00
johan12345
ac1db7f10d fix lint errors 2021-05-24 10:48:18 +02:00
johan12345
ef99441844 various dependency updates 2021-05-23 23:05:29 +02:00
Johan von Forstner
c4e3534682 Update README.md
fix typo in README
2021-05-23 21:59:38 +02:00
johan12345
d335d7cab0 Release 0.7.2 2021-05-09 15:25:03 +02:00
johan12345
f7c3faa7bd Chargeprice: show my tariffs first in overview 2021-05-09 15:18:35 +02:00
johan12345
1338e2306e Chargeprice: show currently selected currency as summary 2021-05-09 14:45:31 +02:00
johan12345
83a2b42408 Chargeprice: add "my plans" selection preference 2021-05-09 14:44:18 +02:00
johan12345
0ce5938f5b Chargeprice: show provider name only if tariff name doesn't start with it 2021-05-09 14:38:57 +02:00
johan12345
5ab50e04ae README.md: add info about necessary Chargeprice API key 2021-04-28 22:51:05 +02:00
johan12345
ee0fd4e8d8 Chargeprice: store charging range (#86) 2021-04-28 22:47:42 +02:00
johan12345
369b7d9410 Chargeprice: implement currency selection (#86) 2021-04-28 22:41:08 +02:00
johan12345
c9a0b270cd Chargeprice: do not show two error messages if car was not yet selected 2021-04-28 22:38:56 +02:00
johan12345
c8aa64fa7c fix missing German translation 2021-04-24 19:41:39 +02:00
Johan von Forstner
d5b18bd6fb README: Update Features 2021-04-23 08:12:19 +02:00
johan12345
eb7ade5e48 Release 0.7.1 2021-04-22 22:52:47 +02:00
johan12345
a59444e24b Android Auto: handle network connection issues 2021-04-22 22:48:47 +02:00
johan12345
c6b7157d5b GoingElectricApi: avoid crash when opening hours don't match expected format
(not sure why this happens)
2021-04-22 22:09:19 +02:00
johan12345
3d9a622f09 Chargeprice battery range slider: only update after sliding 2021-04-22 21:47:02 +02:00
johan12345
50bb245777 fix tests 2021-04-21 08:45:17 +02:00
johan12345
128cebfc20 fix multiline titles of preferences (#82) 2021-04-20 23:22:18 +02:00
johan12345
c106bc40cc Chargeprice: detect which connectors are compatible with vehicle before sending request (#82) 2021-04-20 23:20:04 +02:00
johan12345
52af10d549 Chargeprice: update "my vehicle" preference summary when changed (#82) 2021-04-20 21:53:51 +02:00
johan12345
8c03d1e9eb Release 0.7.0 2021-04-18 14:41:19 +02:00
johan12345
f1d49e317d Live data: differentiate between occupied and broken chargers (fixes #73) 2021-04-18 14:17:34 +02:00
johan12345
f3be8ed97b Chargeprice: add network error handling 2021-04-18 13:59:26 +02:00
johan12345
258edb87c9 add ability to pass Chargeprice API key in encrypted form to Gradle build
(needed for F-Droid)
2021-04-18 13:51:23 +02:00
Johan von Forstner
703dd40879 Merge pull request #76 from johan12345/chargeprice
Native integration of Chargeprice.app
2021-04-18 00:13:44 +02:00
johan12345
18f7ed19e0 add Chargeprice API Key to Travis CI build 2021-04-18 00:12:46 +02:00
johan12345
3d30e746a0 Implement Chargeprice GUI 2021-04-18 00:12:43 +02:00
johan12345
51d085dbb0 Implement Chargeprice API 2021-04-18 00:11:37 +02:00
johan12345
66b4627d21 Fix positioning of drawer logo on devices with display cutout 2021-04-17 00:05:12 +02:00
johan12345
99263e9a66 add toast with information about GoingElectric.de edit page 2021-04-16 23:46:54 +02:00
johan12345
999c5b0836 Further restrict intent filters to avoid handling intents to GoingElectric.de charge card info 2021-04-16 23:41:41 +02:00
johan12345
52aa1b198d Show information about barrier-free charging (fixes #77) 2021-04-16 22:55:30 +02:00
johan12345
36a702a6f4 Support geo: intents that only send a text and not the geo coordinates
such as those from aCalendar (#79)
2021-04-16 22:25:16 +02:00
johan12345
512be8b0c9 feature graphic: convert text to paths 2021-04-12 21:53:12 +02:00
johan12345
3dabd07969 use feature graphic as logo in GitHub README.md 2021-04-12 21:52:26 +02:00
johan12345
29bec90001 feature graphic: embed font 2021-04-12 21:51:28 +02:00
johan12345
1191ac732b Android Auto: fix crash when charger has no photo 2021-04-12 21:21:05 +02:00
johan12345
a80fcebe94 Release 0.6.1 2021-04-11 21:51:02 +02:00
johan12345
35b21b10e3 detail view: display long names in a better way 2021-04-11 21:42:13 +02:00
johan12345
22d8f9a628 detail view: add fault report icon 2021-04-11 21:37:44 +02:00
johan12345
42e8d999d3 fix crash on Android Auto
(unbinding service that was not bound)
2021-04-10 19:39:06 +02:00
johan12345
cf4d18e23e fix another crash related to location service 2021-04-10 19:34:57 +02:00
johan12345
bfa1c45ae6 Android Auto: increase search radius to 25 km if not enough chargers found within 5 km radius 2021-04-09 23:01:01 +02:00
johan12345
6e888499c4 Avoid repetitive requests to GE API when location is enabled, but not moving 2021-04-08 23:09:39 +02:00
johan12345
9223b70eba When moving map, close map layers menu (fixes #74) 2021-04-06 22:54:43 +02:00
johan12345
fe55855876 update GitHub token for Travis CI 2021-04-06 22:38:37 +02:00
johan12345
6aa8a3d7a2 Release 0.6.0 2021-04-05 22:52:34 +02:00
johan12345
887702b729 Add Android Auto information dialog 2021-04-05 22:42:26 +02:00
johan12345
0417c4f1ae Show GoingElectric verified state 2021-04-05 22:01:52 +02:00
johan12345
0b95785f49 add Android Auto screenshots 2021-04-05 21:22:44 +02:00
Johan von Forstner
2772e9ad4d Merge pull request #63 from johan12345/android-auto
Android Auto support
2021-04-05 21:14:16 +02:00
johan12345
8a16fa3a5c Android Auto: update car app library 2021-04-05 21:13:53 +02:00
johan12345
84d3127675 Android Auto: migrate to new version of car app library 2021-04-05 21:13:53 +02:00
Johan von Forstner
e684fbc0dc Android Auto: avoid crash after maximum number of updates is reached 2021-04-05 21:13:53 +02:00
Johan von Forstner
bb92d26be9 Android Auto: display fault reports 2021-04-05 21:13:53 +02:00
Johan von Forstner
f74bb8e4a5 Android Auto: fix typo for cost string 2021-04-05 21:13:53 +02:00
Johan von Forstner
5d72be8e87 Android Auto: Add charger icon 2021-04-05 21:13:53 +02:00
Johan von Forstner
04e6f63cd7 Android Auto: Add permission screen, add selection between nearby and favorites 2021-04-05 21:13:53 +02:00
Johan von Forstner
ffb0b77f37 Android Auto: implement detail view to app link 2021-04-05 21:13:53 +02:00
Johan von Forstner
9d621c3149 Android Auto: add more information in detail view 2021-04-05 21:13:52 +02:00
Johan von Forstner
7126c3c67c Android Auto: add detail view with button to navigate 2021-04-05 21:13:52 +02:00
Johan von Forstner
62197f99cb GoingElectricApi: use coroutines for loading charger details 2021-04-05 21:13:51 +02:00
johan12345
db68452f55 Android Auto: initial implementation 2021-04-05 21:13:51 +02:00
johan12345
9ec5010495 NewMotionAvailabilityDetector: fail silently for unknown connector types 2021-04-05 21:03:56 +02:00
johan12345
5978b90da2 fix crash if charger ID was not found 2021-04-05 21:00:25 +02:00
johan12345
223d9d394f fix crash if location client is not connected 2021-04-05 20:58:31 +02:00
johan12345
38b82abc48 Preserve map traffic enabled state across app restarts
like map type, which was implemented in 6cb682f0
2021-04-05 20:56:03 +02:00
johan12345
aade4ec488 increase touch target size for search bar 2021-04-05 20:51:33 +02:00
johan12345
38a02f8304 use more restrictive pattern for intent-filter
For example edit button (url ending with /edit/) would try to open in EVMap
2021-04-05 20:47:50 +02:00
johan12345
8f7e1c5629 disable location following when search result is shown 2021-04-05 19:11:09 +02:00
johan12345
0be90d8801 Release 0.5.0 2021-03-28 23:12:37 +02:00
johan12345
4ca9cc68cb Handle intents to https://www.goingelectric.de/stromtankstellen website 2021-03-28 23:02:24 +02:00
johan12345
62e9acf9be throttle repetitive loading of chargepoints to 500 ms 2021-03-28 22:43:08 +02:00
johan12345
6cb682f065 Preserve selected map type across app restarts 2021-03-28 21:46:59 +02:00
johan12345
4cfd5c8ef2 follow current location in map view (fixes #56) 2021-03-28 21:42:26 +02:00
johan12345
24bf66ddbe fix calculation of total chargers from filtered availability introduced in a0b0339c8b 2021-03-28 18:42:07 +02:00
johan12345
a0b0339c8b Handle geo intents to open map (fixes #69) 2021-03-27 21:35:42 +01:00
johan12345
2c9081b313 filter availability displayed in sparse view by selected connectors 2021-03-27 20:58:38 +01:00
johan12345
bd245801b0 refactoring of FilterValues using typealias and extension function 2021-03-27 20:48:15 +01:00
johan12345
11dac62b94 update copyright year 2021-03-24 08:43:25 +01:00
Johan von Forstner
a8bac7875a README.md: document Mapbox API key 2021-02-08 22:17:51 +01:00
johan12345
dbba00b51b Rework filter profile delete undo functionality (similar bug to #70) 2021-01-28 22:45:05 +01:00
johan12345
90cddce54c fix #70: Renaming filter profile resets settings 2021-01-28 21:47:47 +01:00
315 changed files with 20783 additions and 2620 deletions

2
.gitattributes vendored Normal file
View File

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

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

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

View File

@@ -1,10 +1,12 @@
language: java
dist: trusty
dist: focal
env:
global:
- secure: KYdFlMarsyXw+OHht1Atp+Kirbw9O09Ck14EjFuKb1eNtknurZ/tGEXuD+8xWh1W8W21kgHEG7s3rzru53t29buz+FW9f+ZmhEWXFP3OydyvXLw4BAVVOjm6xG2uHX/8MOGLJNM7cfaF25EPQ+kznHe84R29KaLH90mNRr2lPa4VnfbcnvDStiVaez/vJ72UoYSP5HICAzoF70yC3ZvvCK1hZv71UIysCbFE2IkxvMhG9OOGebdnRmFssaRCrvfRLjitobcLzkPWzZZIqdjNASf8/iAxX8VgGBYfVj8ID06AfMrtgXNJRCvcD0LICraQ+WPUbikMunRieGO8PNHSB5vKdPoC50aLUa0RoRb4G3QM1pR2A8xAFlIJFX2R7iY+2t24L9hRFqB98+QoQzutfkAI1T0rzem/wtpZpuan+bDawDJEHGCeYbE0aPDAl6lytgrEE9fRgV3c1jJLQzu0xIWG8YLl3iMg0hL+c0wCKXoeqrfCFS6kYmmG7W+rQp4tCZifvRbWfAXwIPQieffKxqdEuUwiUsYxdzCu9v9uU3nflEOLLuRgeMP3gV8mpur9b5GztpkfgfzcAqsF+NiY01kYgGtrgCYlMy0TxASE+UuALrtkQtU01wwhs9RH7Az0Ib3C+MT5DTjxHQCYETIViocmNEG2vfAbgHazCpGAhcY=
- secure: Hoko50vP+Mwm/O4CWvPvjMxd1gGhi+Bultjyy1WpludSmZFCfKz45Mj1EqzeYk6MeMLvGOEkSLB5wjdXdgJ9j5gEOF5K34k4vESJA7+DqDO3I7Xw9cnWgOXdFqB0qGHar0TVP3Dfg7ZcRgtmeX6t2uoELFLiS9GvTnbZXk4PCUybd979Xi8XHjEQV7+3EZbSjtsI4GFeIK1rrjd0I+UM88zYrWnz1KhdCjWvQb4iZjo+ib6NmGGEMqR7jJPRZz3KB01Y+n5h21qq9/Nv31zQIqt2B5nRxy3vBvvqKapgprIk+hVOpNnBU8w89uWUU6tYUeFk0t7z1TYWjgaBrMmGCM+aKkQ2q5F/ygNzDwB+KkpJx709Yhf0ZX8g2yvkdz+Ok7moYuvmrOPOf4E/U3BlfZxbGtRD2bGYbDgHLFYlTn6v5J2kJHDJAz31yvF5jvJejDaPp2IBVfoMRy7ZJFmGUNHGd9Se6bRwxS++AoobP5WDrBiUXNe2KKDMs3e7vbaO+hLbZ9XHpjeWIJGUfvtTee8EHZqF/8A3ju53V4/R0ehlOv2UZbpYNqcmwrsy9/R4pgMfDkG3Q054LmYrxD8DIC9b8excVMwWRP5aQ1TqZnxO2B1/vJU87RcnGl3jekeHTdHXbRq9BMV4dAdPfB9X3nGIi2GgV0iTBk+24xccOc0=
- secure: RsN/2nBVv0byMzwchuAhDti1AWKECg3Uzqk6Ahgaqg02zx8GHj60j0qNax7KuMUg70X3G4b3m8ZzndAU0wcJ98UyIku7ofUYgXHm0XYKTJwiyyhrKJ8pZ4qoeCoRkitIGIitlb84fSufVamcoLyLNbLUsO5OzL+Uscyhq/BwAhyOhj5FB9DM+GE9ntanQ4m3k4guMyMctR9CGS6Tk4LKJ1WCm0AnjTPalc+we+7yORY2J/9d6VHLKfaFXbpuWmrdnFfDZqxqcUODsxPVE0LuUtXyKQDTjjfQc8106Z/z19AJS+oLm/2UND84PD3MqsjX6EX0/9k3fY0OsoCuQAivDRmtevQ0bDQrTAyeBLcfnPCw/MYiJWyBcaYBAYK42EAfsFTDBRIduFB/Dpvg+8GuLZSdm4xVYpTlQ2pMtlGNWIGIRQsjk9LZ8swq8QBMiiF/bpMGKdfmnyQj8jkEOCWaAzkR9O+4E4Y7PuBENef9XuV0hLMryCrML2YXigxAKkEUOPhfnG+AbEY+g2kAMp+2EtXaG3tsGxoMhEYA+uRd3o2cacQMMwRpnePH5DYg6F05mrvdHPpt+P9UR1iHaHmjBPAYeksmrdP8bS088zcnePgiL6+6N0m3+l8Krihmxg5RyWjnH18IwX4RO+xg4x3cW+zaScCDbbotDMEYtChF+Hs=
- secure: LQHMdhaPUlCuJPFrCPpUphJSY6xzAFI/7RrcAVLtLcPhGdS+MeNifIkkAH7MeitTHroOC0dGkZ4bg/8/7bKfgwY4vPH9P50kZcnX5mI6zfBHgNYJzuthj+vJH9RAtkdQOW9Fe1uPIx8R9GUWUOVnkoJh0PQ1gDXdZW5fePqUtn1kYrcCCBE+Bhe3wz6QzTBqGS1nsVRTxQfSJNGi9uH1oi9kQGgQFuCCiJ/P0A6MIhSItkOfuggx/iorA+iASbhWkB4nXYQBbFe/ZhFJWbVfgYlOM0HtpKh8B2AqKw21Em32JoovCbUof4adkY7cH8/4Rt9SujC9YOw+a6oM+e//jJT0sie77V7zl670j+qODTuNvV4qVUwtoxShyc1Sfbd+Xb0xn/OC7DzBg97YuYCF/84yyuq12rl/cofynWE1L5YvGNSJk241XUw98Bvl0MK4VIfQvG9zJP0HnQZcWKt6kFOIEJSCRbmkd2tPPAZFBXBQf/bvpULOoKwneGJZBSapRoCyGwemM+EAzVB9UOXAqsXZ4FHkt1SSJVrTVwgxvXpCfmF6LZPhbz6nvouRWGsC/GdWjrHtdW5lEOvS27qKEL5rXwQ0o+71ZICGo8j4E0GOHXyi857qZhvO7cbOnts+iiawXiWzPXv2gGGabuqPwcU8JPEoWdaiIaeGUczfjBU=
- secure: fvPVjj3l+TZ7HF5aGn/pmrkipGIrz+MkKNy3I7pnCJSuD/oVp9nQ5ePP/dAhaRThaW+fQbq7hOmCquPAtfoN9CUnHNV2f2l9RavDQIxdqvpXqY13A0BFffZho6A6H2kO7k6kQQPQEhl4SMJjObnX12/YDaTVx3b7aIroEJ8DyY62xGTsjExtaAksuFwUEekjh0MoWICvyBoDfrYhpiEVI2721rGMHu7FIXwmE38+jj7wwZd3Bp37yI9NY/b3ZQ/HUKyYDuoAL0xl5/GaQlRepD0v2xWQUQ40NArHLfMoscXi55UaENuswCg7rt9os8jCcZ8FkZf1cVsQ71JrE0uxgs00Jfjy2QKM5u1XUZefl1Nw5cfCDTWXIEGsz9OGiidFLehWUupX/6C6wr1BStdlRt+6Pt/FXsYHxO/qog++cKqHjOJRXi+raGAb99HhQ/hLnLUMKl5DIWlKF9DImXiOpfYxrgCJc3y91vNX6noJyWYs6PvErMukTsXFHen+fM0NtfTFoKW682oILvXjoeFvuzKpk49+rcpkJbRi5+Zdo/duSPp/flwvC4LOMi0RZOO9TNMhWKdkyWweDr1HEpvQn6RS87rpHzQwRDvm85F+PkZLMMqyWpuxBWbJf0jVbew21KvTJWamuizsIgCebFh0SSxgObzmMbAIFCkzL0PRsms=
- ANDROID_HOME=$HOME/android-sdk
before_install:
- openssl aes-256-cbc -K $encrypted_53968681344a_key -iv $encrypted_53968681344a_iv -in _ci/keystore.jks.enc -out _ci/keystore.jks -d
@@ -34,7 +36,7 @@ cache:
deploy:
provider: releases
api_key:
secure: B+V5Fz8k9HbpecyMjpJuLr8aVBrdwtDBDkQh4YQ8nu+Da4AiYwEJZseWXhOWs+oms0gNen9bBxsakQQKu7GKYDs8gIXZZtANWsc0gse8xo+cYT7NqEM3jP6mM3ytAv7VNRX3N2cdL7xazELK3/5+mghfORAAdXXYKUFGG5eTKoML8zgdPVN8E9QFqiusLXqoKhxOMCSE4NS+Di7CGlUmnidRTWg6yxhE085zljmYv2owS0NRbr5a4/zW6Z9xZPALGAqsOvIvpZHuOC2s0eMJWMmYGkK/Ws/LAVxfj4U+YkFp9hlZC0zEg/JoS19Gf57QmEu+vsoQ3uOBYBFv9NPI/R9kVH6o0hcOxId3J0u+ewSGWuceGLRpizXuMxKIvLTS5j6GWkxdSieWjwh/OuVB+ciAHNM31B7GP4FWnfz0ZaEVxI/tPenNipZdl9oXdyyBQQ00vPlYp0jT80XhaMh5rDwWMUPaEjRafvymcNyqZ0iVOr0rq1CbdT92STMSmA1U3/rmhtCMD5IGD0b+gQl+VpPKe1QXViYftVxCGL+s4ke4DUZD7HR20fGs8zu61Elnwci1HufbetKFL5TmxoKSLkWFSkzrtBaJnEruZIxhNUMkUL2UPynaOcPNzLoumjHXrUb3m3s0yE4OFelmJ6mJfXswP38sS8kj3wB7R/gC4rw=
secure: "XQR4GUrGkPKYVV0xMbJifX/ewKAnenBPlM/pPacQ9irAmYNYa/yEkySz4x1K6MP8cEnuJbxHFakcDqhNRCqD7Cq2NcnCi3qtTEXHK6ApLoVl/92eyiWxu/bYlidOEZb+YPcVNtTR253NiI8GYda+CrhLd4uCmsAgES+XPFJd/t2esMlDOSAp7xalZv/zFhhlB9+SevfPFMc6kkrqeHpKnMs9SK8ltVQmh3nch2KjtDvqgDW6d3nuwn7/HAer6/HY86hmA4Rh6Mo2cV6OloX0bdJ7hvA1GOT4p3+K3lWbTRxzE0o1DXAtT7+D158iKvxHFPuF3h+CTjSlLeiss6kQZL9nFjw/KhAvu+GJOp37PcMoI++mpMiFoWPlzKpp17BVKIDinYbgi8kiU4zG+QHhe2cY85SbfAplXUaysq7uzxEZwEUYHSAHNahshVooXRqvuzkthcH0/nvinfeXrzx2xDvQ3if1NENMRgttwewU0kvU61iKUwpcf/UN2bHK3DaPes0VzSH4PTHAGjoRpksDfqUwb7S8YxbYr+44aMbSPYN8Lbjda0BxPSKWwHM5/pi7FBJN1a1w3t7sV/EiACWUWr8OovmX4ljyCybbR0w9cPzRC1zAYeSUHslLXMTW2Pp9h594RnYh3q3VfeYlFCikFvuvrafwXmTkz35uhLb+2ws="
file:
- app/build/outputs/apk/foss/release/app-foss-release.apk
- app/build/outputs/apk/google/release/app-google-release.apk

View File

@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
addressable (2.7.0)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
atomos (0.1.3)
aws-eventstream (1.1.0)
@@ -125,7 +125,7 @@ GEM
naturally (2.2.0)
os (1.1.1)
plist (3.5.0)
public_suffix (4.0.5)
public_suffix (4.0.6)
rake (13.0.1)
representable (3.0.4)
declarative (< 0.1.0)
@@ -154,6 +154,7 @@ GEM
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unf_ext (0.0.7.7-x64-mingw32)
unicode-display_width (1.7.0)
word_wrap (1.0.0)
@@ -170,6 +171,7 @@ GEM
PLATFORMS
x64-mingw32
x86_64-linux
DEPENDENCIES
fastlane

View File

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

View File

@@ -1,9 +1,9 @@
EVMap [![Build Status](https://travis-ci.org/johan12345/EVMap.svg?branch=master)](https://travis-ci.org/johan12345/EVMap)
EVMap [![Build Status](https://app.travis-ci.com/johan12345/EVMap.svg?branch=master)](https://app.travis-ci.com/johan12345/EVMap)
=====
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/appicon_cropped.svg?sanitize=true" width=80 alt="Logo"/>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/feature_graphic.svg" width=700 alt="Logo"/>
Android app to access the goingelectric.de electric vehicle charging station directory.
Android app to find electric vehicle charging stations.
<a href="https://play.google.com/store/apps/details?id=net.vonforst.evmap" target="_blank">
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="100"/></a>
@@ -14,10 +14,13 @@ Features
--------
- [Material Design](https://material.io/)
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) directory
- Realtime availability information (beta)
- Search places
- Shows all charging stations from the community-maintained [GoingElectric.de](https://www.goingelectric.de/stromtankstellen/) and [Open Charge Map](https://openchargemap.org) directories
- Realtime availability information (only in Europe)
- Search for places
- Advanced filtering options, including saved filter profiles
- Favorites list, also with availability information
- Integrated price comparison using [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- No ads, fully open source
- Compatible with Android 5.0 and above
- Can use Google Maps or Mapbox (OpenStreetMap) as map backends - the version available on F-Droid only uses Mapbox.
@@ -25,27 +28,22 @@ Features
Screenshots
-----------
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/01_main.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/02_detail.png" width=250 alt="Screenshot 2"/>
<img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/01_map.png" width=250 alt="Screenshot 1"/><img src="https://raw.githubusercontent.com/johan12345/EVMap/master/_img/screenshots/phone/en/mapbox/02_detail.png" width=250 alt="Screenshot 2"/>
Development setup
-----------------
The App is developed using Android Studio.
The App is developed using Android Studio and should pretty much work out-of-the-box when you clone
the Git repository and open the project with Android Studio.
For testing the app, you need to obtain API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/)
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated). These APIs need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
The only exception is that you need to obtain some free API keys for the different data sources that
EVMap uses and put them into the app in the form of a resource file called `apikeys.xml` under
`app/src/main/res/values`. You can find more information on which API keys are necessary for which
features and how they can be obtained in our [documentation page](doc/api_keys.md).
```xml
<resources>
<string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">
insert your Google Maps key here
</string>
<string name="goingelectric_key" translatable="false">
insert your GoingElectric key here
</string>
</resources>
```
There are two different build flavors, `google` and `foss`, where only the `google` variant uses
Google Maps data and provides the Android Auto integration. The `foss` variant only uses Mapbox data
and should run on devices without Google Play Services.
We also have a special [documentation page](doc/android_auto.md) on how to test the Android Auto
app.

View File

@@ -0,0 +1,25 @@
<?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_5" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-width:1.7;stroke-miterlimit:10;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
</style>
<circle cx="9" cy="18.7" r="1.4" />
<circle cx="15" cy="18.7" r="1.4" />
<path class="st0" d="M8.9,16.1h6.2c1.5,0,2.7,1.2,2.7,2.7l0,0c0,1.5-1.2,2.7-2.7,2.7H8.9c-1.5,0-2.7-1.2-2.7-2.7l0,0
C6.2,17.3,7.4,16.1,8.9,16.1z" />
<g>
<circle cx="14.7" cy="6.4" r="1.3" />
<circle cx="15.3" cy="10.5" r="0.8" />
<circle cx="8.7" cy="10.5" r="0.8" />
<circle cx="9.3" cy="6.4" r="1.3" />
<circle cx="12" cy="13.1" r="1.3" />
<circle class="st1" cx="12" cy="9.1" r="6.3" />
<rect x="11" y="15.4" width="2" height="1.3" />
<line class="st2" x1="10.9" y1="1.3" x2="13.1" y2="1.3" />
<polygon points="13.1,0.9 13.1,2.4 14.5,3.1 13.8,1 " />
<polygon points="10.9,0.9 10.9,2.4 9.5,3.1 10.2,1 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 1024 500"
style="enable-background:new 0 0 1024 500;" xml:space="preserve">
<?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 1024 500" style="enable-background:new 0 0 1024 500;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E6E6E6;}
.st1{fill:#DCDCDC;}
@@ -15,7 +15,7 @@
.st10{fill:#666666;}
.st11{fill:#D1D1D1;}
.st12{opacity:0.2;fill:#808080;enable-background:new ;}
.st13{opacity:0.5;}
.st13{opacity:0.5;enable-background:new ;}
.st14{fill:#FFB300;}
.st15{fill:#90A4AE;}
.st16{fill:#546E7A;}
@@ -23,10 +23,9 @@
.st18{fill:#FFFFFF;fill-opacity:0.2;}
.st19{fill:#3E2723;fill-opacity:0.2;}
.st20{opacity:0.45;enable-background:new ;}
.st21{font-family:'Roboto-Light';}
.st22{font-size:136.5333px;}
.st21{enable-background:new ;}
</style>
<g id="Ebene_1">
<g id="Ebene_1_1_">
<rect y="-34.4" class="st0" width="1024" height="568.9" />
<g>
<path class="st1"
@@ -35,25 +34,25 @@
d="M145.4,335.9L38.1,228.7c-6-6-6-15.4,0-21.3L91,154.1c5.7-5.7,15.4-5.7,21.3,0l107.2,107.2" />
<path class="st2" d="M131.7,209.9L93.6,248c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C134.8,202.2,134.5,207,131.7,209.9z" />
<path class="st3" d="M223.3,265.1c-2,2-5.4,2-7.4,0l-107.2-107c-3.7-3.7-10.2-4-13.9,0L41.8,211c-3.7,3.7-3.7,10,0,13.9L149,332.2
c2,2,2,5.4,0,7.4c-2,2-5.4,2-7.4,0L34.4,232.4c-8-8-8-20.8,0-28.7l52.9-53.2c8-8,20.8-8,28.7,0l107.2,107.2
<path class="st3" d="M223.3,265.1c-2,2-5.4,2-7.4,0l-107.2-107c-3.7-3.7-10.2-4-13.9,0l-53,52.9c-3.7,3.7-3.7,10,0,13.9L149,332.2
c2,2,2,5.4,0,7.4s-5.4,2-7.4,0L34.4,232.4c-8-8-8-20.8,0-28.7l52.9-53.2c8-8,20.8-8,28.7,0l107.2,107.2
C225.3,260,225.3,263.1,223.3,265.1z" />
<path class="st4" d="M131.7,209.9L93.6,248c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C134.8,202.2,134.5,207,131.7,209.9z" />
<path class="st3" d="M135.4,213.6l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L54.6,227c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
<path class="st3" d="M135.4,213.6l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L54.6,227c-4.8-4.8-4.8-13.1,0-17.9L92.7,171
c4.8-4.8,13.1-4.8,17.9,0l24.7,24.7C140.5,200.5,140.5,208.5,135.4,213.6z M62,216.4c-0.9,0.9-0.9,2.3,0,3.1l24.7,24.7
c0.9,0.9,2.3,0.9,3.1,0l38.1-38.1c0.9-0.9,0.9-2.3,0-3.1l-24.7-24.7c-0.9-0.9-2.3-0.9-3.1,0L62,216.4z M233.8,254.6l-95.3,95.3
c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0S235.8,252.6,233.8,254.6z M228.4,238.3c-4.8,4.8-13.1,4.8-17.9,0
l-43-42.7c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l5.4-5.4c4.8-4.8,13.1-4.8,17.9,0l43,43
c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-59.2c-2-2-2-5.4,0-7.4c2-2,5.4-2,7.4,0l58.9,58.9
c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0S235.8,252.6,233.8,254.6z M228.4,238.3c-4.8,4.8-13.1,4.8-17.9,0
l-43-42.7c-0.9-0.9-2.3-0.9-3.1,0L159,201c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l5.4-5.4c4.8-4.8,13.1-4.8,17.9,0l43,43
c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-59.2c-2-2-2-5.4,0-7.4s5.4-2,7.4,0l58.9,58.9
C233.2,225.3,233.2,233.5,228.4,238.3z" />
<path class="st3" d="M174.6,163.8l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7l-4.8-4.6c-2-2-2-5.4,0-7.4
c2-2,5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C176.4,158.4,176.6,161.8,174.6,163.8z M160.4,163.2l3.1-3.1
l-13.9-13.9l-3.1,3.1L160.4,163.2z" />
<path class="st3" d="M174.6,163.8l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7L132,139c-2-2-2-5.4,0-7.4
s5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C176.4,158.4,176.6,161.8,174.6,163.8z M160.4,163.2l3.1-3.1l-13.9-13.9
l-3.1,3.1L160.4,163.2z" />
<g>
<path class="st5" d="M163.8,290.1c-0.6,0.6-1.4,1.1-2.6,1.4c-2.8,0.6-5.7-1.1-6.3-3.7l-3.7-16.2l-3.7,5.4c-1.1,1.7-3.1,2.6-5.1,2
c-2-0.6-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2
c2,0.6,3.7,2,4,4l6.3,27C165.5,287.3,165,289,163.8,290.1z" />
s-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2s3.7,2,4,4l6.3,27
C165.5,287.3,165,289,163.8,290.1z" />
</g>
</g>
<g>
@@ -66,11 +65,11 @@
<path class="st3" d="M156.4,462.5l-40.1,40.1c-4.6,4.6-11.7,4.6-16.2,0l-68.3-68.3c-4.6-4.6-4.6-11.7,0-16.2L72,378
c4.6-4.6,11.7-4.6,16.2,0l68.3,68.3C161,450.8,161,457.9,156.4,462.5z M37.8,424.4c-1.1,1.1-1.1,2.8,0,4l68.3,68.3
c1.1,1.1,2.8,1.1,4,0l40.1-40.1c1.1-1.1,1.1-2.8,0-4l-68.3-68.3c-1.1-1.1-2.8-1.1-4,0L37.8,424.4z" />
<path class="st2" d="M111.2,487.5c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l25,25
<path class="st2" d="M111.2,487.5c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l25,25
C112.9,483.2,112.9,485.8,111.2,487.5z M71.1,447.4c-0.9,0.9-2,1.4-3.1,1.1c-1.1,0-2.3-0.3-3.1-1.1c-0.9-0.9-1.4-2-1.1-3.1
c0-0.3,0-0.6,0-0.9s0-0.6,0.3-0.9s0.3-0.6,0.3-0.9c0.9-1.1,2-2,3.4-2c1.1,0,2.3,0.3,3.1,1.1c0.3,0.3,0.3,0.3,0.6,0.6
c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9s0,0.6,0,0.9C72.2,445.4,71.7,446.6,71.1,447.4z" />
<path class="st3" d="M68,393.9c-1.7,1.7-4.3,1.7-6,0l-9.1-9.1L39,398.8l1.1,1.1c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-4-4
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9s0,0.6,0,0.9C72.2,445.4,71.7,446.6,71.1,447.4z" />
<path class="st3" d="M68,393.9c-1.7,1.7-4.3,1.7-6,0l-9.1-9.1l-13.9,14l1.1,1.1c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-4-4
c-1.7-1.7-1.7-4.3,0-6l20.2-20.2c1.7-1.7,4.3-1.7,6,0l11.9,11.9C69.7,389.7,69.7,392.2,68,393.9z" />
</g>
<g>
@@ -78,35 +77,35 @@
C390.3,425.8,354.1,476.4,333.4,497.2z" />
<path class="st5" d="M293.5,387.7l48.1-11.7c1.7-0.3,2.6,1.7,1.1,2.6l-28.4,18.8l9.7,9.7c0.9,0.9,0.3,2.3-0.9,2.3l-43.8,6.8
c-1.4,0.3-2.3-1.7-1.1-2.6l23.6-14.5l-9.4-9.4C292.4,389.1,292.7,388,293.5,387.7z" />
<path class="st3" d="M374.6,440l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-8,8
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6C378.9,441.4,376,441.4,374.6,440z" />
<path class="st3" d="M374.6,440l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-8,8
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6S376,441.4,374.6,440z" />
<path class="st3" d="M327.4,503.2L226.7,402.5c-1.7-1.7-1.7-4.3,0-6l73.4-73.4c7.1-7.1,18.8-7.1,26.2,0l70.3,70.3l0,0l8,8
c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C331.7,504.9,328.8,504.9,327.4,503.2z
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C331.7,504.9,328.8,504.9,327.4,503.2z
M235.8,399.6l94.4,94.4c21.6-21.6,54.6-69.1,58.9-96.1l-68.8-68.8c-4-4-10.2-4-13.9,0C306.1,329.4,235.8,399.6,235.8,399.6z" />
<path class="st3" d="M327.4,503.2L222.7,398.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
<path class="st3" d="M327.4,503.2L222.7,398.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
C331.7,504.9,328.8,504.9,327.4,503.2z" />
<path class="st2" d="M251.2,410.1c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
S256.6,415.3,251.2,410.1L251.2,410.1z M315.4,474.4c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0
c5.4,5.4,13.9,5.4,19.3,0S320.9,479.8,315.4,474.4L315.4,474.4z" />
<path class="st3" d="M228.7,432.3c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
C247.2,439.4,235.8,439.4,228.7,432.3z M248,413c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4s9.7,3.7,13.4,0
S256.6,415.3,251.2,410.1L251.2,410.1z M315.4,474.4c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
S320.9,479.8,315.4,474.4L315.4,474.4z" />
<path class="st3" d="M228.7,432.3c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
C247.2,439.4,235.8,439.4,228.7,432.3z M248,413c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4s9.7,3.7,13.4,0
C251.7,422.7,251.7,416.7,248,413z" />
<g>
<path class="st3" d="M293.3,496.6c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
<path class="st3" d="M293.3,496.6c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
C311.5,503.7,300.1,503.7,293.3,496.6z M312.6,477.6c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4s9.7,3.7,13.4,0
C316.3,487.2,316.3,481.3,312.6,477.6z" />
</g>
<g>
<path class="st3" d="M293.3,496.6l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
<path class="st3" d="M293.3,496.6l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
C297.5,498.3,295,498.3,293.3,496.6z" />
</g>
<g>
<path class="st3" d="M194.6,398.2c-0.3-0.3-0.3-0.3-0.6-0.6c-0.3-0.3-0.3-0.6-0.3-0.9c0-0.3-0.3-0.6-0.3-0.9s0-0.6,0-0.9
c0-0.3,0-0.6,0-0.9c0-0.3,0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6s0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3
c0.3,0,0.6,0,0.9-0.3c0.3-0.3,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9s0,0.6,0,0.9c0,0.3,0,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.3,0.9
c-0.3,0.3-0.3,0.6-0.6,0.6s-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3c-0.3,0-0.6,0.3-0.9,0.3s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0
c-0.3,0-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3C195.1,398.8,194.8,398.5,194.6,398.2z" />
<path class="st3" d="M194.6,398.2c-0.3-0.3-0.3-0.3-0.6-0.6c-0.3-0.3-0.3-0.6-0.3-0.9s-0.3-0.6-0.3-0.9s0-0.6,0-0.9s0-0.6,0-0.9
s0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6s0.3-0.6,0.6-0.6s0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3c0.3,0,0.6,0,0.9-0.3
c0.3-0.3,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9s0,0.6,0,0.9s0,0.6,0,0.9s0,0.6-0.3,0.9s-0.3,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.6,0.6
s-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3c-0.3,0-0.6,0.3-0.9,0.3s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0c-0.3,0-0.6,0-0.9-0.3
c-0.3,0-0.6-0.3-0.9-0.3C195.1,398.8,194.8,398.5,194.6,398.2z" />
</g>
</g>
<g>
@@ -121,46 +120,43 @@
l0.3,0.3C940.1,42.4,940.1,60.8,928.7,72.2z M893.2,36.7c-8,8-8,21,0,29.3l0.3,0.3c8,8,21,8,29.3,0c8-8,8-21,0-29.3l-0.3-0.3
C914.2,28.7,901.1,28.7,893.2,36.7z" />
<path class="st3" d="M896.9,55.2c-2,2-2,5.1,0,7.1c2,2,5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C902.3,53.2,898.8,53.2,896.9,55.2
L896.9,55.2z M911.4,40.6c-2,2-2,5.1,0,7.1c2,2,5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C916.8,38.7,913.4,38.7,911.4,40.6L911.4,40.6z" />
L896.9,55.2z M911.4,40.6c-2,2-2,5.1,0,7.1s5.1,2,7.1,0l0,0c2-2,2-5.1,0-7.1C916.8,38.7,913.4,38.7,911.4,40.6L911.4,40.6z" />
<path class="st2"
d="M971.9,88.7l10.8,10.8c7.4,7.4,7.4,19.3,0,26.7l0,0c-7.4,7.4-19.3,7.4-26.7,0l-10.8-10.8L971.9,88.7z" />
<path class="st3" d="M985.9,129.4c-9.1,9.1-23.6,9.1-32.7,0l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l8.8,8.8
c5.7,5.7,15.1,5.7,20.8,0c5.7-5.7,5.7-15.1,0-20.8l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8
C995,105.8,995,120.3,985.9,129.4z" />
<path class="st3" d="M953.2,112.9c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l9.4,9.4
C954.9,108.6,954.9,111.2,953.2,112.9z M969.4,97c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0
l9.4,9.4C971.1,92.4,971.1,95.3,969.4,97z" />
<path class="st3" d="M978.8,88.2l-34.1,34.1c-1.7,1.7-4.3,1.7-6,0c-1.7-1.7-1.7-4.3,0-6l34.1-34.1c1.7-1.7,4.3-1.7,6,0
C980.5,83.9,980.5,86.4,978.8,88.2z M994.4,137.9c-1.7,1.7-4.3,1.7-6,0l-8.5-8.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0
l8.5,8.5C995.8,133.4,996.1,136.2,994.4,137.9z" />
<path class="st3" d="M985.9,129.4c-9.1,9.1-23.6,9.1-32.7,0l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8
c5.7,5.7,15.1,5.7,20.8,0s5.7-15.1,0-20.8l-8.8-8.8c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.8,8.8C995,105.8,995,120.3,985.9,129.4z" />
<path class="st3" d="M953.2,112.9c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l9.4,9.4
C954.9,108.6,954.9,111.2,953.2,112.9z M969.4,97c-1.7,1.7-4.3,1.7-6,0l-9.4-9.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l9.4,9.4
C971.1,92.4,971.1,95.3,969.4,97z" />
<path class="st3" d="M978.8,88.2l-34.1,34.1c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l34.1-34.1c1.7-1.7,4.3-1.7,6,0
C980.5,83.9,980.5,86.4,978.8,88.2z M994.4,137.9c-1.7,1.7-4.3,1.7-6,0l-8.5-8.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l8.5,8.5
C995.8,133.4,996.1,136.2,994.4,137.9z" />
<g>
<path class="st5" d="M966.5,110.1c-1.4,1.4-1.4,4,0,5.4c1.4,1.4,4,1.4,5.4,0l0,0c1.4-1.4,1.4-4,0-5.4
C970.5,108.6,968,108.6,966.5,110.1L966.5,110.1z" />
<path class="st5" d="M966.5,110.1c-1.4,1.4-1.4,4,0,5.4s4,1.4,5.4,0l0,0c1.4-1.4,1.4-4,0-5.4C970.5,108.6,968,108.6,966.5,110.1
L966.5,110.1z" />
</g>
</g>
<g>
<path class="st7" d="M486.4,389.4l97.6,97.6l10.8-10.8c6-6,6-15.6,0-21.6l-38.7-38.7l-1.7-0.6c-6.5-2.3-8.5-10.5-3.7-15.6
l-3.4-3.4C529.9,378.6,509.4,379.4,486.4,389.4" />
<path class="st7" d="M486.4,389.4L584,487l10.8-10.8c6-6,6-15.6,0-21.6l-38.7-38.7l-1.7-0.6c-6.5-2.3-8.5-10.5-3.7-15.6l-3.4-3.4
C529.9,378.6,509.4,379.4,486.4,389.4" />
<path class="st8" d="M517.7,382.6C517.4,382.6,517.4,382.6,517.7,382.6c-1.1-0.3-2-0.3-2.8-0.3c-8.8,0.3-18.2,2.8-28.2,6.8l0,0
l31,31C527.9,410.1,527.9,393.1,517.7,382.6 M599.3,465c-10.2-7.7-25-6.8-34.7,2.6l19.3,19.3l10.8-10.8
C597.9,473,599.3,469,599.3,465" />
<path class="st3" d="M581.1,490.1l-97.6-97.8c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8
l0.3,0.3c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2L584,481l8-8c4.3-4.3,4.3-11.4,0-15.6
l-34.4-34.4c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33,33
L557.6,423c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33,33
c7.7,7.7,7.7,20.2,0,27.9l-10.8,10.8C585.4,491.8,582.8,491.5,581.1,490.1z" />
<path class="st2" d="M509.4,390.5c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
C515.7,406.4,515.7,396.8,509.4,390.5L509.4,390.5z M565.8,434.6c-3.4-3.4-8.8-3.4-11.9,0c-3.1,3.4-3.4,8.8,0,11.9
c3.4,3.1,8.8,3.4,11.9,0C569.2,443.4,569.2,438,565.8,434.6z" />
<path class="st3" d="M484.7,415.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0c7.7,7.7,7.7,20.2,0,27.9
C504.9,422.9,492.4,422.9,484.7,415.3z M506.6,393.6c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
C510.9,405,510.9,397.9,506.6,393.6z" />
<path class="st2" d="M509.4,390.5c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C515.7,406.4,515.7,396.8,509.4,390.5
L509.4,390.5z M565.8,434.6c-3.4-3.4-8.8-3.4-11.9,0c-3.1,3.4-3.4,8.8,0,11.9s8.8,3.4,11.9,0C569.2,443.4,569.2,438,565.8,434.6z" />
<path class="st3" d="M484.7,415.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C504.9,422.9,492.4,422.9,484.7,415.3
z M506.6,393.6c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0C510.9,405,510.9,397.9,506.6,393.6z" />
<path class="st2" d="M594.5,475.6c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0S600.5,481.5,594.5,475.6L594.5,475.6z
" />
<path class="st3" d="M569.7,500.3c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C589.9,508,577.4,508,569.7,500.3z
M591.4,478.4c-4.3-4.3-11.4-4.3-15.6,0s-4.3,11.4,0,15.6s11.4,4.3,15.6,0S595.6,483,591.4,478.4z" />
<path class="st3" d="M569.7,500.3l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
C574,502,571.4,502,569.7,500.3z M459.7,390.2c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1
c2.3,0,4.3,2,4.3,4.3c0,1.1-0.3,2.3-1.1,3.1c-0.9,0.9-2,1.4-3.1,1.1C461.4,391.4,460.5,391.1,459.7,390.2z" />
<path class="st3" d="M569.7,500.3l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
S571.4,502,569.7,500.3z M459.7,390.2c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1c2.3,0,4.3,2,4.3,4.3
c0,1.1-0.3,2.3-1.1,3.1c-0.9,0.9-2,1.4-3.1,1.1C461.4,391.4,460.5,391.1,459.7,390.2z" />
</g>
<g>
<path class="st2" d="M306.9,39.8l-35-35.3c-7.7-7.7-19.9-8.2-28.2-1.7L218.2,23l70.3,70.3L308.6,68
@@ -174,17 +170,16 @@
<path class="st3" d="M196.8,64.3c-1.7-1.7-1.7-4.3,0-6L215,40.1c4-4,4-10.2,0-13.9c-4-4-10.2-4-13.9,0l-18.2,18.2
c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0s7.1,18.8,0,26.2l-18.2,18.2C201.1,66,198.5,66,196.8,64.3z
M267.1,134.5c-1.7-1.7-1.7-4.3,0-6l18.2-18.2c4-4,4-10.2,0-13.9c-4-3.7-10.2-4-13.9,0l-18.2,18.2c-1.7,1.7-4.3,1.7-6,0
c-1.7-1.7-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0c7.1,7.1,7.1,18.8,0,26.2l-18.2,18.2
C271.6,136.2,268.8,136.2,267.1,134.5z" />
s-1.7-4.3,0-6l18.2-18.2c7.1-7.1,18.8-7.1,26.2,0c7.1,7.1,7.1,18.8,0,26.2l-18.2,18.2C271.6,136.2,268.8,136.2,267.1,134.5z" />
<path class="st3" d="M289.6,90.1l-0.3-0.3c-19.1-25.9-41.8-48.6-67.4-67.7c-1.7-1.4-2.3-4-0.9-6c1.4-1.7,4-2.3,6-0.9
c26.5,19.6,49.8,43,69.4,69.4c1.4,1.7,1.1,4.6-0.9,6C293.5,91.6,291.3,91.6,289.6,90.1z" />
<path class="st3" d="M215,25.9l-0.3-0.3c-1.4-1.7-1.1-4.6,0.6-6l25.6-20.2c10.2-8,24.7-7.1,34.1,2l35,35.3c9.4,9.4,10,23.9,2,34.1
l-20.2,25.3c-1.4,1.7-4,2-6,0.6c-2-1.4-2-4-0.6-6l20.2-25.3c5.4-6.8,4.8-16.5-1.1-22.8L268.8,7.4c-6.3-6.3-15.9-6.8-22.8-1.4
l-25.6,20.2C218.7,27.8,216.5,27.6,215,25.9z M264.2,125.7l-13.9,13.9l-8-8l13.9-13.9 M193.7,55.2l-13.9,13.9l-8-8l13.9-13.9" />
l-20.2,25.3c-1.4,1.7-4,2-6,0.6s-2-4-0.6-6l20.2-25.3c5.4-6.8,4.8-16.5-1.1-22.8L268.8,7.4C262.5,1.1,252.9,0.6,246,6l-25.6,20.2
C218.7,27.8,216.5,27.6,215,25.9z M264.2,125.7l-13.9,13.9l-8-8l13.9-13.9 M193.7,55.2l-13.9,13.9l-8-8l13.9-13.9" />
<path class="st3" d="M247.2,142.8l-8.2-8.2c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2
l11.1-11.1c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-13.7,14.2C251.4,144.5,248.9,144.5,247.2,142.8z M176.6,72.2l-8-8
c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2l11.1-11.1c1.7-1.7,4.3-1.7,6,0
c1.7,1.7,1.7,4.3,0,6l-13.9,13.9C180.9,73.9,178.3,73.9,176.6,72.2z" />
l11.1-11.1c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-13.7,14.2C251.4,144.5,248.9,144.5,247.2,142.8z M176.6,72.2l-8-8
c-1.7-1.7-1.7-4.3,0-6l13.9-13.9c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-11.1,11.1l2,2l11.1-11.1c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6
l-13.9,13.9C180.9,73.9,178.3,73.9,176.6,72.2z" />
<path class="st3" d="M270.2,137.6l-96.4-96.4c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l96.4,96.4c1.7,1.7,1.7,4.3,0,6
C274.5,139.4,271.9,139.4,270.2,137.6z" />
<path class="st2" d="M239.5,88.7l-16.8-16.8c-1.7-1.7-1.7-4.3,0-6l10-10c6.3-6.3,16.5-6.3,23,0l0,0c6.3,6.3,6.3,16.5,0,23l-10,10
@@ -194,7 +189,7 @@
<path class="st2" d="M213,28.1c-2.8-2.8-7.4-2.8-10,0c-2.8,2.8-2.8,7.4,0,10c2.8,2.8,7.4,2.8,10,0C215.6,35.2,215.6,30.7,213,28.1
z M283.3,98.4c-2.8-2.8-7.4-2.8-10,0s-2.8,7.4,0,10s7.4,2.8,10,0C285.9,105.5,286.2,101.2,283.3,98.4z" />
<path class="st3" d="M253.2,148.7L169,64.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.2,84.2c1.7,1.7,1.7,4.3,0,6
C257.4,150.4,254.9,150.4,253.2,148.7z M274.2,157.8c-1.7-1.7-4.3-1.7-6,0s-1.7,4.3,0,6c1.7,1.7,4.3,1.7,6,0
C257.4,150.4,254.9,150.4,253.2,148.7z M274.2,157.8c-1.7-1.7-4.3-1.7-6,0s-1.7,4.3,0,6s4.3,1.7,6,0
C275.9,162.1,275.9,159.5,274.2,157.8z" />
</g>
<g>
@@ -208,7 +203,7 @@
<path class="st3" d="M467.1,235.8l-40.4,40.1c-4.6,4.6-11.7,4.6-16.2,0l-68.3-68.3c-4.6-4.6-4.6-11.7,0-16.2l40.1-40.1
c4.6-4.6,11.7-4.6,16.2,0l68.3,68.3C471.3,224.1,471.3,231.2,467.1,235.8z M348.2,197.7c-1.1,1.1-1.1,2.8,0,4l68.3,68.3
c1.1,1.1,2.8,1.1,4,0l40.4-40.1c1.1-1.1,1.1-2.8,0-4l-68.3-68.3c-1.1-1.1-2.8-1.1-4,0L348.2,197.7z" />
<path class="st2" d="M421.5,260.8c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l25,25
<path class="st2" d="M421.5,260.8c-1.7,1.7-4.3,1.7-6,0l-25-25c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l25,25
C423.3,256.5,423.3,259.1,421.5,260.8z M381.4,220.7c-0.9,0.9-2,1.4-3.1,1.1c-1.1,0-2.3-0.3-3.1-1.1c-0.9-0.9-1.4-2-1.1-3.1
c0-0.3,0-0.6,0-0.9c0-0.3,0-0.6,0.3-0.9c0.3-0.3,0.3-0.6,0.3-0.9c0.9-1.1,2-2,3.4-2c1.1,0,2.3,0.3,3.1,1.1
c0.3,0.3,0.3,0.3,0.6,0.6c0.3,0.3,0.3,0.6,0.3,0.9c0,0.3,0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9C382.9,218.7,382.3,219.8,381.4,220.7z
@@ -222,25 +217,25 @@
d="M905.4,490.4L798.2,383.1c-6-6-6-15.4,0-21.3l53.2-53.2c5.7-5.7,15.4-5.7,21.3,0l107.2,107.2" />
<path class="st2" d="M892,364.3l-38.1,38.1c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C894.9,356.7,894.9,361.5,892,364.3z" />
<path class="st3" d="M983.3,419.5c-2,2-5.4,2-7.4,0l-107-107c-3.7-3.7-10.2-4-13.9,0l-53.2,53.2c-3.7,3.7-3.7,10,0,13.9
l107.2,107.2c2,2,2,5.4,0,7.4c-2,2-5.4,2-7.4,0L794.5,387.1c-8-8-8-20.8,0-28.7l53.2-53.2c8-8,20.8-8,28.7,0l107.2,107.2
<path class="st3" d="M983.3,419.5c-2,2-5.4,2-7.4,0l-107-107c-3.7-3.7-10.2-4-13.9,0l-53.2,53.2c-3.7,3.7-3.7,10,0,13.9L909,486.8
c2,2,2,5.4,0,7.4s-5.4,2-7.4,0L794.5,387.1c-8-8-8-20.8,0-28.7l53.2-53.2c8-8,20.8-8,28.7,0l107.2,107.2
C985.6,414.4,985.3,417.5,983.3,419.5z" />
<path class="st4" d="M892,364.3l-38.1,38.1c-2.8,2.8-7.7,2.8-10.5,0l-24.7-24.7c-2.8-2.8-2.8-7.7,0-10.5l38.1-38.1
c2.8-2.8,7.7-2.8,10.5,0l24.7,24.7C894.9,356.7,894.9,361.5,892,364.3z" />
<path class="st3" d="M895.7,368l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0l-24.7-24.7c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
<path class="st3" d="M895.7,368l-38.1,38.1c-4.8,4.8-13.1,4.8-17.9,0L815,381.4c-4.8-4.8-4.8-13.1,0-17.9l38.1-38.1
c4.8-4.8,13.1-4.8,17.9,0l24.7,24.7C900.6,355,900.6,362.9,895.7,368z M822.3,370.9c-0.9,0.9-0.9,2.3,0,3.1l24.7,24.7
c0.9,0.9,2.3,0.9,3.1,0l38.1-38.1c0.9-0.9,0.9-2.3,0-3.1l-24.7-24.7c-0.9-0.9-2.3-0.9-3.1,0L822.3,370.9z M994.1,409l-95.3,95.3
c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0C996.1,403.6,996.1,407,994.1,409z M988.4,392.8
c-4.8,4.8-13.1,4.8-17.9,0l-43-43c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0c-2-2-2-5.4,0-7.4l5.4-5.4
c4.8-4.8,13.1-4.8,17.9,0l43,43c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-58.9c-2-2-2-5.4,0-7.4c2-2,5.4-2,7.4,0
l58.9,58.9C993.3,379.7,993.3,388,988.4,392.8z" />
c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l95.3-95.3c2-2,5.4-2,7.4,0C996.1,403.6,996.1,407,994.1,409z M988.4,392.8
c-4.8,4.8-13.1,4.8-17.9,0l-43-43c-0.9-0.9-2.3-0.9-3.1,0l-5.4,5.4c-2,2-5.4,2-7.4,0s-2-5.4,0-7.4l5.4-5.4
c4.8-4.8,13.1-4.8,17.9,0l43,43c0.9,0.9,2.3,0.9,3.1,0c0.9-0.9,0.9-2.3,0-3.1l-58.9-58.9c-2-2-2-5.4,0-7.4s5.4-2,7.4,0l58.9,58.9
C993.3,379.7,993.3,388,988.4,392.8z" />
<path class="st3" d="M934.7,318.3l-10.5,10.5c-2,2-5.4,2-7.4,0l-21.6-21.6c-2-2-2-5.4,0-7.4l1.7-1.7l-4.8-4.8c-2-2-2-5.4,0-7.4
c2-2,5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C936.7,312.9,936.7,316.3,934.7,318.3z M920.5,317.7l3.1-3.1
l-13.9-13.9l-3.1,3.1L920.5,317.7z" />
s5.4-2,7.4,0l4.8,4.8l1.4-1.4c2-2,5.4-2,7.4,0l21.3,21.3C936.7,312.9,936.7,316.3,934.7,318.3z M920.5,317.7l3.1-3.1l-13.9-13.9
l-3.1,3.1L920.5,317.7z" />
<g>
<path class="st5" d="M923.9,444.6c-0.6,0.6-1.4,1.1-2.6,1.4c-2.8,0.6-5.7-1.1-6.3-3.7l-3.7-16.2l-3.7,5.4c-1.1,1.7-3.1,2.6-5.1,2
c-2-0.6-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2
c2,0.6,3.7,2,4,4l6.3,27C925.6,441.7,925.3,443.4,923.9,444.6z" />
s-3.7-2-4-4l-6.5-27c-0.6-2.8,1.1-5.7,3.7-6.3c2.8-0.6,5.7,1.1,6.3,3.7l3.7,16.2l3.7-5.4c1.1-1.7,3.1-2.6,5.1-2s3.7,2,4,4l6.3,27
C925.6,441.7,925.3,443.4,923.9,444.6z" />
</g>
</g>
<g>
@@ -251,9 +246,9 @@
<path class="st12" d="M749.8,413.3l3.7,3.7c1.1,1.1,1.4,2.8,0.6,4.6L708.6,496l-10-10l51.8-68.3
C751.2,416.1,750.9,414.4,749.8,413.3z" />
<g>
<path class="st3" d="M706.6,498L663,454.5c-0.6-0.6-0.9-1.4-0.9-2.3c0-0.9,0.6-1.7,1.4-2l74.5-45.2c2.6-1.4,5.7-1.1,8,0.9
l9.4,9.4c2,2,2.6,5.4,0.9,8l-45.5,74.2c-0.6,0.9-1.1,1.1-2,1.4C708,498.9,707.1,498.6,706.6,498z M669.6,453.1l38.4,38.4
l43.5-71.7c0.3-0.3,0.3-0.9,0-0.9l-9.4-9.4c-0.3-0.3-0.6-0.3-0.9,0L669.6,453.1z" />
<path class="st3" d="M706.6,498L663,454.5c-0.6-0.6-0.9-1.4-0.9-2.3s0.6-1.7,1.4-2L738,405c2.6-1.4,5.7-1.1,8,0.9l9.4,9.4
c2,2,2.6,5.4,0.9,8l-45.5,74.2c-0.6,0.9-1.1,1.1-2,1.4C708,498.9,707.1,498.6,706.6,498z M669.6,453.1l38.4,38.4l43.5-71.7
c0.3-0.3,0.3-0.9,0-0.9l-9.4-9.4c-0.3-0.3-0.6-0.3-0.9,0L669.6,453.1z" />
<path class="st3" d="M655.6,447.1c-1.1-1.1-1.1-2.8,0-4s2.8-1.1,4,0l58,58l0,0c1.1,1.1,1.1,2.8,0,4s-2.8,1.1-4,0L655.6,447.1
L655.6,447.1z" />
</g>
@@ -274,22 +269,21 @@
c0.3-0.6,0.3-1.1,0.6-2l-23.3-23.3c-2.8-2.8-7.1-2.8-10,0l-2.3,2.3c-2.8,2.8-2.8,7.1,0,10L664.2,347.8L664.2,347.8z" />
<path class="st3" d="M623.8,389.9l-0.3-0.3c-2.8-2.8-2.6-7.4,0.3-10.2c1.7-1.7,4.3-1.7,6,0c0.6,0.6,1.1,1.4,1.1,2.3l3.1-2.6
c14.5-11.7,25-27.3,31.3-44.9l1.1-3.4c2.8-8.8,0.6-18.2-6-24.7l-64.3-64.3c-9.4-9.4-24.7-9.4-34.1,0l-35.8,35.8
c-1.7,1.7-4.3,1.7-6,0c-1.7-1.7-1.7-4.3,0-6l35.8-35.8c12.8-12.8,33.6-12.8,46.4,0l64.3,64.3c8.8,8.8,11.9,21.6,8,33.6l-1.1,3.4
c-6.5,19.1-18.2,35.8-33.8,48.6l-5.7,4.8C630.6,392.8,626.6,392.5,623.8,389.9z M644.8,388.2c-1.7-1.7-1.7-4.3,0-6l18.2-18.2
c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-18.2,18.2C649.4,389.9,646.5,389.9,644.8,388.2z" />
<path class="st3" d="M599.6,361.2l-46.9-46.9c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l47.2,46.9c1.7,1.7,1.7,4.3,0,6
c-1.7,1.7-4.3,1.7-6,0s-1.7-4.3,0-6l35.8-35.8c12.8-12.8,33.6-12.8,46.4,0l64.3,64.3c8.8,8.8,11.9,21.6,8,33.6l-1.1,3.4
c-6.5,19.1-18.2,35.8-33.8,48.6l-5.7,4.8C630.6,392.8,626.6,392.5,623.8,389.9z M644.8,388.2c-1.7-1.7-1.7-4.3,0-6L663,364
c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-18.2,18.2C649.4,389.9,646.5,389.9,644.8,388.2z" />
<path class="st3" d="M599.6,361.2l-46.9-46.9c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l47.2,46.9c1.7,1.7,1.7,4.3,0,6
C604.2,362.9,601.3,362.9,599.6,361.2z M531.3,292.7L516,277.3c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l15.4,15.4c1.7,1.7,1.7,4.3,0,6
C535.6,294.4,533,294.4,531.3,292.7z M671.9,299.2l-66.6-66.6c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l66.6,66.6c1.7,1.7,1.7,4.3,0,6
S673.6,300.9,671.9,299.2z M640.3,379.7c-1.7-1.7-1.7-4-0.3-6c7.7-8.8,5.4-26.5-4.6-36.4l-87.6-87.6c-1.7-1.7-1.7-4.3,0-6
s4.3-1.7,6,0l87.6,87.6c13.1,13.1,15.4,35.8,4.8,48.1C644.8,381.1,642.3,381.4,640.3,379.7L640.3,379.7z M565.2,246.9l-6.3-6.3
c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C569.7,248.6,566.9,248.6,565.2,246.9z M577.4,242.9
l-6.3-6.3c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C581.7,244.6,579.1,244.6,577.4,242.9z" />
c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C569.7,248.6,566.9,248.6,565.2,246.9z M577.4,242.9l-6.3-6.3
c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l6.3,6.3c1.7,1.7,1.7,4.3,0,6C581.7,244.6,579.1,244.6,577.4,242.9z" />
<path class="st2" d="M623.5,359.5c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
C628.6,373.4,628.6,364.9,623.5,359.5L623.5,359.5z" />
<path class="st3" d="M601,382c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,7.1,18.5,0,25.3
C619.5,389.1,608.1,388.8,601,382z M620.4,362.6c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
C624.1,372.3,624.1,366.3,620.4,362.6z" />
<path class="st2" d="M535.3,290.7c-5.4,5.4-5.4,13.9,0,19.3c5.4,5.4,13.9,5.4,19.3,0c2.3-2.3,3.7-5.1,4-8.2l-15.1-15.1
<path class="st3" d="M601,382c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0s7.1,18.5,0,25.3C619.5,389.1,608.1,388.8,601,382z
M620.4,362.6c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0C624.1,372.3,624.1,366.3,620.4,362.6z" />
<path class="st2" d="M535.3,290.7c-5.4,5.4-5.4,13.9,0,19.3s13.9,5.4,19.3,0c2.3-2.3,3.7-5.1,4-8.2l-15.1-15.1
C540.4,287.3,537.6,288.4,535.3,290.7z" />
<path class="st3" d="M532.5,313.1c-7.1-7.1-6.8-18.5,0-25.3c2.8-2.8,6.5-4.6,10.8-5.1c1.4,0,2.6,0.3,3.4,1.1l15.1,15.1
c0.9,0.9,1.4,2.3,1.1,3.4c-0.3,4-2.3,8-5.1,10.8C550.7,320.3,539.3,320,532.5,313.1z M542.2,291.5c-1.4,0.3-2.6,1.1-3.7,2.3
@@ -300,7 +294,7 @@
</g>
<g>
<path class="st3" d="M623.8,385.4l-2.6-2.6c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l2.6,2.6c1.7,1.7,1.7,4.3,0,6
C628.1,387.1,625.5,387.1,623.8,385.4z" />
S625.5,387.1,623.8,385.4z" />
</g>
</g>
<g>
@@ -308,35 +302,35 @@
C611.3,101.5,575.1,152.2,554.4,172.9z" />
<path class="st5" d="M514.8,63.4l48.1-11.7c1.7-0.3,2.6,1.7,1.1,2.6l-28.4,18.8l9.7,9.7c0.9,0.9,0.3,2.3-0.9,2.3l-43.8,6.8
c-1.4,0.3-2.3-1.7-1.1-2.6l23.6-14.5l-9.4-9.4C513.4,64.8,513.7,63.7,514.8,63.4z" />
<path class="st3" d="M595.6,115.5l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0c1.7,1.7,1.7,4.3,0,6l-8,8
<path class="st3" d="M595.6,115.5l-15.1-15.1c-7.1-7.1-7.1-18.8,0-26.2l8-8c1.7-1.7,4.3-1.7,6,0s1.7,4.3,0,6l-8,8
c-4,4-4,10.2,0,13.9l15.1,15.1c1.7,1.7,1.7,4.3,0,6C599.9,117.2,597.3,117.2,595.6,115.5z" />
<path class="st3" d="M548.4,178.9L447.7,78.2c-1.7-1.7-1.7-4.3,0-6l73.4-73.4c7.1-7.1,18.8-7.1,26.2,0l70.3,70.3l0,0l8,8
c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C552.7,180.6,549.8,180.6,548.4,178.9z
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-2.3-2.3c-8.2,31.3-41.5,76.2-60,94.7l-3.1,3.1C552.7,180.6,549.8,180.6,548.4,178.9z
M456.8,75.4l94.4,94.4c21.6-21.6,54.6-69.1,58.9-96.1L541.3,4.8c-4-4-10.2-4-13.9,0C527.1,5.1,456.8,75.4,456.8,75.4z" />
<path class="st3" d="M548.4,178.9L443.7,74.2c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
<path class="st3" d="M548.4,178.9L443.7,74.2c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l104.7,104.7c1.7,1.7,1.7,4.3,0,6
C552.7,180.6,549.8,180.6,548.4,178.9z" />
<path class="st2" d="M472.2,85.6c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
<path class="st2" d="M472.2,85.6c-5.4-5.4-13.9-5.4-19.3,0s-5.4,13.9,0,19.3l0,0c5.4,5.4,13.9,5.4,19.3,0
C477.6,99.8,477.6,91,472.2,85.6L472.2,85.6z M536.5,150.2c-5.4-5.4-13.9-5.4-19.3,0c-5.4,5.4-5.4,13.9,0,19.3l0,0
c5.4,5.4,13.9,5.4,19.3,0C541.9,164.1,541.9,155.3,536.5,150.2L536.5,150.2z" />
<path class="st3" d="M450,108.1c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c6.8,6.8,6.8,18.5,0,25.3
<path class="st3" d="M450,108.1c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0s6.8,18.5,0,25.3
C468.2,114.9,456.8,115.2,450,108.1z M469,88.7c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
C472.7,98.4,473,92.4,469,88.7z" />
<g>
<path class="st3" d="M514.3,172.3c-7.1-7.1-6.8-18.5,0-25.3c6.8-6.8,18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
C532.8,179.5,521.4,179.5,514.3,172.3z M533.6,153c-3.7-3.7-9.7-3.7-13.4,0c-3.7,3.7-3.7,9.7,0,13.4c3.7,3.7,9.7,3.7,13.4,0
<path class="st3" d="M514.3,172.3c-7.1-7.1-6.8-18.5,0-25.3s18.5-6.8,25.3,0c7.1,7.1,6.8,18.5,0,25.3
C532.8,179.5,521.4,179.5,514.3,172.3z M533.6,153c-3.7-3.7-9.7-3.7-13.4,0s-3.7,9.7,0,13.4s9.7,3.7,13.4,0
C537,162.7,537.3,156.7,533.6,153z" />
</g>
<g>
<path class="st3" d="M514.3,172.3l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6c1.7-1.7,4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
<path class="st3" d="M514.3,172.3l-84.5-84.5c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l84.5,84.5c1.7,1.7,1.7,4.3,0,6
C518.5,174.1,516,174.1,514.3,172.3z" />
</g>
<g>
<path class="st3" d="M415.6,73.9c-0.3-0.3-0.3-0.3-0.6-0.6c0-0.3-0.3-0.6-0.3-0.9s-0.3-0.6-0.3-0.9s0-0.6,0-0.9s0-0.6,0-0.9
c0-0.3,0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3
s0.6,0,0.9-0.3s0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0.3c0.3,0.3,0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1
c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9c0,0.3,0,0.6,0,0.9c0,0.3,0,0.6,0,0.9s0,0.6-0.3,0.9c0,0.3-0.3,0.6-0.3,0.9
c-0.3,0.3-0.3,0.6-0.6,0.6c-0.3,0-0.3,0.3-0.6,0.6c-0.3,0.3-0.6,0.3-0.9,0.3s-0.6,0.3-0.9,0.3c-0.3,0-0.6,0-0.9,0
c-0.3,0-0.6,0-0.9,0s-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3C416.1,74.5,415.9,73.9,415.6,73.9z" />
s0-0.6,0.3-0.9c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.3-0.6,0.6-0.6c0.3,0,0.3-0.3,0.6-0.6c0.3-0.3,0.6-0.3,0.9-0.3s0.6,0,0.9-0.3
s0.6,0,0.9,0s0.6,0,0.9,0s0.6,0,0.9,0.3s0.6,0.3,0.9,0.3c0.6,0.3,0.9,0.6,1.1,1.1c0.3,0.3,0.3,0.6,0.3,0.9s0.3,0.6,0.3,0.9
s0,0.6,0,0.9s0,0.6,0,0.9s0,0.6-0.3,0.9c0,0.3-0.3,0.6-0.3,0.9c-0.3,0.3-0.3,0.6-0.6,0.6c-0.3,0-0.3,0.3-0.6,0.6
c-0.3,0.3-0.6,0.3-0.9,0.3s-0.6,0.3-0.9,0.3s-0.6,0-0.9,0s-0.6,0-0.9,0s-0.6,0-0.9-0.3c-0.3,0-0.6-0.3-0.9-0.3
C416.1,74.5,415.9,73.9,415.6,73.9z" />
</g>
</g>
<path class="st3"
@@ -350,8 +344,8 @@
<path class="st3" d="M78.2,8.2l-8.8-8.5c-1.1-1.1-1.1-2.8,0-4c1.1-1.1,2.8-1.1,4,0l8.8,8.8c1.1,1.1,1.1,2.8,0,4
C81.4,9.6,79.4,9.4,78.2,8.2z" />
<g>
<path class="st2" d="M57.7,69.9L44.7,56.6c-3.7-3.7-3.7-10,0-13.7L82.2,5.4c3.7-3.7,10-3.7,13.7,0l13.4,13.4
c3.7,3.7,3.7,10,0,13.7L71.4,69.9C67.7,73.6,61.7,73.6,57.7,69.9z" />
<path class="st2" d="M57.7,69.9l-13-13.3c-3.7-3.7-3.7-10,0-13.7L82.2,5.4c3.7-3.7,10-3.7,13.7,0l13.4,13.4c3.7,3.7,3.7,10,0,13.7
L71.4,69.9C67.7,73.6,61.7,73.6,57.7,69.9z" />
</g>
<g>
<path class="st11" d="M95.6,18.2c-4.3-4.3-11.1-4.3-15.1,0c-4.3,4.3-4.3,11.1,0,15.1l0,0c4.3,4.3,11.1,4.3,15.1,0
@@ -371,8 +365,8 @@
C32.4,85.3,30.4,85.3,29.3,84.2z" />
</g>
<g>
<path class="st5" d="M802.4,224.7l-70.5-70.5l-46.9,46.9c-4.3,4.3-4.3,11.4,0,15.6l54.9,54.9c4.3,4.3,11.4,4.3,15.6,0L802.4,224.7
z" />
<path class="st5" d="M802.4,224.7l-70.5-70.5L685,201.1c-4.3,4.3-4.3,11.4,0,15.6l54.9,54.9c4.3,4.3,11.4,4.3,15.6,0L802.4,224.7z
" />
<path class="st2" d="M731,207.9l33.3-8.2c1.1-0.3,1.7,1.1,0.9,1.7l-19.6,13.1l6.8,6.8c0.6,0.6,0.3,1.4-0.6,1.7l-30.2,4.8
c-1.1,0.3-1.4-1.1-0.6-1.7l16.2-9.7l-6.5-6.5C729.9,209,730.2,208.2,731,207.9z" />
<path class="st6" d="M794.7,217l-45.5,45.5c-0.9,0.9-2.3,0.9-3.1,0L712.5,229c-2.3-2.3-5.7-2.3-8,0l0,0c-2.3,2.3-2.3,5.7,0,8
@@ -383,11 +377,11 @@
c-3.1,3.1-3.1,8,0,11.1l54.9,54.9c3.1,3.1,8,3.1,11.1,0l46.9-46.9c1.4-1.4,3.4-1.4,4.8,0c1.4,1.4,1.4,3.4,0,4.8l-46.9,46.9
C752.1,279.6,743.3,279.6,737.6,274.2z" />
<path class="st2" d="M714,188c-1.4-1.4-1.4-3.4,0-4.8l23.6-23.6c1.4-1.4,3.4-1.4,4.8,0c1.4,1.4,1.4,3.4,0,4.8L718.8,188
C717.4,189.1,715.4,189.1,714,188z M702.3,199.7c-0.3-0.3-0.3-0.3-0.3-0.6c-0.3-0.3-0.3-0.3-0.3-0.6c0-0.3,0-0.3-0.3-0.6
C717.4,189.1,715.4,189.1,714,188z M702.3,199.7c-0.3-0.3-0.3-0.3-0.3-0.6c-0.3-0.3-0.3-0.3-0.3-0.6s0-0.3-0.3-0.6
c0-0.3,0-0.3,0-0.6s0-0.6,0-0.6c0-0.3,0-0.3,0.3-0.6c0-0.3,0.3-0.3,0.3-0.6s0.3,0,0.3-0.3c0.3-0.3,0.3-0.3,0.6-0.3
c0.3,0,0.3-0.3,0.6-0.3s0.6-0.3,0.6-0.3c0.3,0,0.3,0,0.6,0s0.6,0,0.6,0s0.3,0,0.6,0.3c0.3,0,0.3,0.3,0.6,0.3
c0.9,0.6,1.4,1.7,1.4,2.8c0,0.3,0,0.6,0,0.6c0,0.3,0,0.3-0.3,0.6c0,0.3-0.3,0.3-0.3,0.6s-0.6,0.3-0.6,0.3
c-0.6,0.6-1.4,1.1-2.3,0.9C703.7,200.8,702.9,200.2,702.3,199.7z" />
s0.3-0.3,0.6-0.3s0.6-0.3,0.6-0.3c0.3,0,0.3,0,0.6,0s0.6,0,0.6,0s0.3,0,0.6,0.3c0.3,0,0.3,0.3,0.6,0.3c0.9,0.6,1.4,1.7,1.4,2.8
c0,0.3,0,0.6,0,0.6c0,0.3,0,0.3-0.3,0.6c0,0.3-0.3,0.3-0.3,0.6s-0.6,0.3-0.6,0.3c-0.6,0.6-1.4,1.1-2.3,0.9
C703.7,200.8,702.9,200.2,702.3,199.7z" />
<path class="st3" d="M804.1,230.9l-78.2-78.2c-1.4-1.4-1.4-3.4,0-4.8l9.4-9.4c1.4-1.4,3.4-1.4,4.8,0l78.2,78.2
c1.4,1.4,1.4,3.4,0,4.8l-9.4,9.4C807.5,232.4,805.3,232.4,804.1,230.9z M732.7,150.4l73.7,73.7l4.8-4.8l-73.7-73.7L732.7,150.4z" />
<path class="st3" d="M758.6,166.7c-1.4-1.4-1.4-3.4,0-4.8l7.1-7.1l-7.1-7.1l-0.9,0.9c-1.4,1.4-3.4,1.4-4.8,0
@@ -401,20 +395,20 @@
<path class="st8" d="M909.4,164.4C909.4,164.1,909.4,164.1,909.4,164.4c-1.1-0.3-2-0.3-2.8-0.3c-8.8,0.3-18.2,2.8-28.2,6.8l0,0
l31,31C919.6,191.7,919.6,174.6,909.4,164.4 M991,246.6c-10.2-7.7-25-6.8-34.7,2.6l19.3,19.3l10.8-10.8
C989.6,254.6,991,250.6,991,246.6" />
<path class="st3" d="M972.8,271.6l-97.6-97.6c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8
l0.3,0.3c1.7,1.7,1.7,4.3,0,6c-1.7,1.7-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2l89.9,89.9l8-8
c4.3-4.3,4.3-11.4,0-15.6l-34.4-34.7c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5
l33.3,33c7.7,7.7,7.7,20.2,0,27.9l-10.8,10.8C977.4,273.3,974.5,273.3,972.8,271.6z" />
<path class="st2" d="M901.4,172.3c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
C907.4,188,907.4,178.3,901.4,172.3L901.4,172.3z M957.7,216.4c-3.4-3.4-8.8-3.4-11.9,0c-3.4,3.4-3.4,8.8,0,11.9
c3.4,3.1,8.8,3.4,11.9,0C960.9,225,960.9,219.6,957.7,216.4z" />
<path class="st3" d="M876.7,197.1c-7.7-7.7-7.7-20.2,0-27.9c7.7-7.7,20.2-7.7,27.9,0s7.7,20.2,0,27.9
C896.6,204.8,884.3,204.8,876.7,197.1z M898.3,175.2c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
C902.5,186.6,902.5,179.7,898.3,175.2z" />
<path class="st2" d="M986.2,257.1c-6-6-15.6-6-21.6,0c-6,6-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0
C992.1,272.8,992.1,263.1,986.2,257.1L986.2,257.1z" />
<path class="st3" d="M972.8,271.6L875.2,174c-1.1-1.1-1.4-2.3-1.1-3.7c0.3-1.4,1.1-2.6,2.6-3.1c27.9-11.9,48.6-9.4,65.7,8l0.3,0.3
c1.7,1.7,1.7,4.3,0,6s-4.3,1.7-6,0l-0.3-0.3c-13.4-13.4-29.3-16.2-50.6-8.2l89.9,89.9l8-8c4.3-4.3,4.3-11.4,0-15.6l-34.4-34.7
c-1.1-1.1-1.4-2.3-1.1-3.7l1.7-10.8c0.3-2.3,2.6-4,4.8-3.4c2.3,0.3,4,2.6,3.4,4.8l-1.4,8.5l33.3,33c7.7,7.7,7.7,20.2,0,27.9
l-10.8,10.8C977.4,273.3,974.5,273.3,972.8,271.6z" />
<path class="st2" d="M901.4,172.3c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C907.4,188,907.4,178.3,901.4,172.3
L901.4,172.3z M957.7,216.4c-3.4-3.4-8.8-3.4-11.9,0c-3.4,3.4-3.4,8.8,0,11.9c3.4,3.1,8.8,3.4,11.9,0
C960.9,225,960.9,219.6,957.7,216.4z" />
<path class="st3" d="M876.7,197.1c-7.7-7.7-7.7-20.2,0-27.9s20.2-7.7,27.9,0s7.7,20.2,0,27.9C896.6,204.8,884.3,204.8,876.7,197.1
z M898.3,175.2c-4.3-4.3-11.4-4.3-15.6,0c-4.3,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0C902.5,186.6,902.5,179.7,898.3,175.2z
" />
<path class="st2" d="M986.2,257.1c-6-6-15.6-6-21.6,0s-6,15.6,0,21.6l0,0c6,6,15.6,6,21.6,0C992.1,272.8,992.1,263.1,986.2,257.1
L986.2,257.1z" />
<path class="st3" d="M961.4,281.9c-7.7-7.7-7.7-20.2,0-27.9c7.7-7.7,20.2-7.7,27.9,0c7.7,7.7,7.7,20.2,0,27.9
C981.6,289.5,969.1,289.5,961.4,281.9z M983.3,260.2c-4.3-4.3-11.4-4.3-15.6,0s-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
C981.6,289.5,969.1,289.5,961.4,281.9z M983.3,260.2c-4.3-4.3-11.4-4.3-15.6,0c-4.2,4.3-4.3,11.4,0,15.6c4.3,4.3,11.4,4.3,15.6,0
C987.6,271.6,987.6,264.5,983.3,260.2z" />
<path class="st3" d="M961.4,281.9l-98.1-98.1c-1.7-1.7-1.7-4.3,0-6s4.3-1.7,6,0l98.1,98.1c1.7,1.7,1.7,4.3,0,6
S963.1,283.6,961.4,281.9z M851.3,171.8c-0.9-0.9-1.4-2-1.1-3.1c0-1.1,0.3-2.3,1.1-3.1c0.9-0.9,2-1.4,3.1-1.1c2.3,0,4.3,2,4.3,4.3
@@ -423,7 +417,7 @@
<g>
<path class="st3" d="M789.9,46.3c0-8.5,6.8-15.4,15.4-15.4c8.5,0,15.4,6.8,15.4,15.4s-6.8,15.4-15.4,15.4 M752.6,8.8
c0-8.5,6.8-15.4,15.4-15.4s15.4,6.8,15.4,15.4s-6.8,15.4-15.4,15.4" />
<path class="st11" d="M782.2,91.6l-59.7-59.7c-0.6-0.6-0.6-1.7,0-2.6l19.3-19.3c0.6-0.6,1.7-0.6,2.6,0l59.7,59.7
<path class="st11" d="M782.2,91.6l-59.7-59.7c-0.6-0.6-0.6-1.7,0-2.6L741.8,10c0.6-0.6,1.7-0.6,2.6,0l59.7,59.7
c0.6,0.6,0.6,1.7,0,2.6l-19.3,19.3C783.9,92.4,782.8,92.4,782.2,91.6z" />
<path class="st2" d="M725.6,26.4l-3.1,3.1c-0.6,0.6-0.6,1.7,0,2.6l5.1,5.1l40.4-3.4l0,0L757.2,23L725.6,26.4z M737.3,46.9
l-0.6-0.6l11.4,11.4l40.4-3.1l0,0l-10.8-10.8L737.3,46.9z M801,75.6l3.1-3.1c0.6-0.6,0.6-1.7,0-2.6l-5.1-5.1L758.6,68l0,0
@@ -437,7 +431,7 @@
</g>
<rect y="-34.4" class="st13" width="1024" height="568.9" />
</g>
<g id="Ebene_2">
<g id="Ebene_2_1_">
<g>
<g>
<g>
@@ -455,19 +449,36 @@
</g>
<g>
<g>
<path class="st17" d="M267.6,140.1c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6c2.1,0,3.8-1.5,4-3.6
<path class="st17" d="M267.6,140.1c-37,0-67,30-67,67c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6
c6.8-72,63.2-98.4,63.2-148.9C334.5,169.9,304.5,140.1,267.6,140.1z" />
<path class="st18" d="M267.6,141.6c36.8,0,66.5,29.6,67,66.1c0-0.2,0-0.4,0-0.6c0-37-30-67-67-67s-67,29.8-67,67
c0,0.2,0,0.4,0,0.6C201,171.2,230.8,141.6,267.6,141.6L267.6,141.6z" />
<path class="st19" d="M271.6,354.4c-0.2,2.1-1.9,3.6-4,3.6s-3.8-1.5-4-3.6c-6.5-71.8-62.5-98.2-63-148.1c0,0.4,0,0.6,0,1.1
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6c2.1,0,3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
c0,50.5,56.4,76.9,63.2,148.9c0.2,2.1,1.9,3.6,4,3.6s3.8-1.5,4-3.6c6.8-72,63.2-98.4,63.2-148.9c0-0.4,0-0.6,0-1.1
C334.1,256.1,278.1,282.5,271.6,354.4L271.6,354.4z" />
</g>
<path class="st20"
d="M252.2,174.4v40.6h11v33.2l25.8-44.4h-14.8l14.8-29.6C289.1,174.4,252.2,174.4,252.2,174.4z" />
d="M252.2,174.4V215h11v33.2l25.8-44.4h-14.8l14.8-29.6C289.1,174.4,252.2,174.4,252.2,174.4z" />
</g>
</g>
<text transform="matrix(1 0 0 1 417.7245 289.5375)" class="st2 st21 st22">EVMap</text>
<g class="st21">
<path class="st2"
d="M483.6,243h-45.4v39.6h52.2v6.9H430v-97.1h60.1v6.9h-51.9v36.7h45.4V243z" />
<path class="st2"
d="M536.9,277.5l0.5,2.1l0.6-2.1l30.5-85.1h9l-36.1,97.1h-7.9l-36.1-97.1h8.9L536.9,277.5z" />
<path class="st2" d="M602.7,192.5l35.8,85.7l35.9-85.7h10.9v97.1h-8.2v-42.3l0.7-43.3l-36.1,85.6h-6.3l-36-85.3l0.7,42.7v42.5
h-8.2v-97.1H602.7z" />
<path class="st2" d="M753.7,289.5c-0.8-2.3-1.3-5.6-1.5-10.1c-2.8,3.6-6.4,6.5-10.7,8.4c-4.3,2-8.9,3-13.8,3
c-6.9,0-12.5-1.9-16.8-5.8c-4.3-3.9-6.4-8.8-6.4-14.7c0-7,2.9-12.6,8.8-16.7c5.8-4.1,14-6.1,24.4-6.1h14.5v-8.2
c0-5.2-1.6-9.2-4.8-12.2c-3.2-3-7.8-4.4-13.9-4.4c-5.6,0-10.2,1.4-13.8,4.3c-3.6,2.8-5.5,6.3-5.5,10.3l-8-0.1
c0-5.7,2.7-10.7,8-14.9c5.3-4.2,11.9-6.3,19.7-6.3c8,0,14.4,2,19,6c4.6,4,7,9.6,7.2,16.8v34.1c0,7,0.7,12.2,2.2,15.7v0.8H753.7z
M728.6,283.8c5.3,0,10.1-1.3,14.3-3.9c4.2-2.6,7.3-6,9.2-10.3v-15.9h-14.3c-8,0.1-14.2,1.5-18.7,4.4c-4.5,2.8-6.7,6.7-6.7,11.6
c0,4,1.5,7.4,4.5,10.1S723.8,283.8,728.6,283.8z" />
<path class="st2" d="M839.3,254.2c0,11.2-2.5,20.2-7.5,26.8c-5,6.6-11.6,9.9-20,9.9c-9.9,0-17.4-3.5-22.7-10.4v36.8h-7.9v-99.9
h7.4l0.4,10.2c5.2-7.7,12.7-11.5,22.6-11.5c8.6,0,15.4,3.3,20.3,9.8c4.9,6.5,7.4,15.6,7.4,27.2V254.2z M831.3,252.8
c0-9.2-1.9-16.5-5.7-21.8c-3.8-5.3-9-8-15.8-8c-4.9,0-9.1,1.2-12.6,3.5c-3.5,2.4-6.2,5.8-8.1,10.3v34.6c1.9,4.1,4.6,7.3,8.2,9.5
c3.6,2.2,7.8,3.3,12.6,3.3c6.7,0,11.9-2.7,15.7-8C829.4,270.7,831.3,263,831.3,252.8z" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="363.80554mm"
height="72.554214mm" viewBox="0 0 1289.0747 257.0819" id="svg4985" version="1.1"
inkscape:version="0.92.3 (2405546, 2018-03-11)" sodipodi:docname="logo_text_small2.svg"
inkscape:export-filename="/home/nih/Desktop/cp/logos/powered_by/logo_text_white_small.png"
inkscape:export-xdpi="42.009968" inkscape:export-ydpi="42.009968">
<defs id="defs4987">
<inkscape:perspective sodipodi:type="inkscape:persp3d" inkscape:vp_x="0 : 152.57443 : 1"
inkscape:vp_y="0 : 1000.0001 : 0" inkscape:vp_z="305.14877 : 152.57443 : 1"
inkscape:persp3d-origin="152.57439 : 101.71629 : 1" id="perspective4145" />
</defs>
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0"
inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="1" inkscape:cx="572.73795"
inkscape:cy="173.48708" inkscape:document-units="px" inkscape:current-layer="layer4"
showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0"
fit-margin-bottom="0" inkscape:window-width="1857" inkscape:window-height="1052"
inkscape:window-x="63" inkscape:window-y="0" inkscape:window-maximized="1" />
<metadata id="metadata4990">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:groupmode="layer" id="layer4" inkscape:label="Layer 2"
transform="translate(-220.97188,-392.31605)">
<g aria-label="chargeprice" transform="matrix(0.93750004,0,0,0.93750004,231.60533,392.30136)"
style="font-style:normal;font-weight:normal;font-size:25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="flowRoot825">
<path
d="m 350.50175,132.92433 q 15.168,0 25.152,4.8 10.176,4.8 10.176,12.288 0,3.264 -2.112,5.952 -2.112,2.496 -5.376,2.496 -2.496,0 -4.032,-0.768 -1.344,-0.768 -3.84,-2.496 -1.152,-1.152 -3.648,-2.688 -2.304,-1.152 -6.528,-1.92 -4.224,-0.768 -7.68,-0.768 -9.984,0 -17.664,4.608 -7.68,4.608 -11.904,12.864 -4.224,8.064 -4.224,18.048 0,10.176 4.032,18.24 4.224,8.064 11.712,12.672 7.488,4.608 17.088,4.608 9.984,0 16.128,-3.072 1.344,-0.768 3.648,-2.496 1.92,-1.536 3.264,-2.304 1.536,-0.768 3.648,-0.768 3.84,0 5.952,2.496 2.304,2.304 2.304,6.144 0,4.032 -5.184,8.064 -4.992,3.84 -13.632,6.336 -8.448,2.496 -18.24,2.496 -14.592,0 -25.728,-6.72 -11.136,-6.912 -17.28,-18.816 -5.952,-12.096 -5.952,-26.88 0,-14.784 6.336,-26.688 6.336,-12.096 17.664,-18.816 11.328,-6.912 25.92,-6.912 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path824" />
<path
d="m 455.46275,133.50033 q 33.792,0 33.792,41.856 v 51.264 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.528,-2.688 -2.496,-2.688 -2.496,-6.528 v -51.264 q 0,-24.96 -21.12,-24.96 -11.328,0 -18.816,7.296 -7.488,7.104 -7.488,17.664 v 51.264 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -123.648 q 0,-3.840001 2.496,-6.528001 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528001 v 48.576 q 4.8,-7.488 13.44,-12.672 8.64,-5.376 18.432,-5.376 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path826" />
<path
d="m 597.95075,133.88433 q 4.032,0 6.528,2.688 2.688,2.496 2.688,6.72 v 83.328 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -4.992 q -4.992,6.72 -13.632,11.52 -8.64,4.608 -18.624,4.608 -13.056,0 -23.808,-6.72 -10.56,-6.72 -16.704,-18.624 -5.952,-12.096 -5.952,-27.072 0,-14.976 5.952,-26.88 6.144,-12.096 16.704,-18.816 10.56,-6.72 23.232,-6.72 10.176,0 18.816,4.224 8.832,4.224 14.016,10.752 v -4.608 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 z m -39.168,86.976 q 9.024,0 15.936,-4.608 7.104,-4.608 10.944,-12.672 4.032,-8.064 4.032,-18.24 0,-9.984 -4.032,-18.048 -3.84,-8.064 -10.944,-12.672 -6.912,-4.8 -15.936,-4.8 -9.024,0 -16.128,4.608 -6.912,4.608 -10.944,12.672 -3.84,8.064 -3.84,18.24 0,10.176 3.84,18.24 4.032,8.064 10.944,12.672 7.104,4.608 16.128,4.608 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path828" />
<path
d="m 684.21275,132.92433 q 4.992,0 8.64,2.688 3.648,2.496 3.648,6.336 0,4.608 -2.496,7.104 -2.304,2.304 -5.76,2.304 -1.728,0 -5.184,-1.152 -4.032,-1.344 -6.336,-1.344 -5.952,0 -11.712,4.224 -5.568,4.032 -9.216,11.328 -3.456,7.104 -3.456,15.936 v 46.272 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -82.176 q 0,-3.84 2.496,-6.528 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 v 9.792 q 4.224,-9.408 12.672,-15.168 8.448,-5.952 19.2,-6.144 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path830" />
<path
d="m 794.32175,133.88433 q 4.032,0 6.528,2.688 2.688,2.496 2.688,6.72 v 84.48 q 0,15.552 -6.72,25.92 -6.528,10.56 -17.856,15.552 -11.328,4.992 -25.536,4.992 -7.68,0 -18.048,-2.688 -10.176,-2.688 -13.056,-5.568 -5.952,-3.072 -5.952,-7.68 0,-1.152 0.768,-3.072 2.112,-4.8 7.104,-4.8 2.496,0 5.376,1.152 15.36,5.952 24,5.952 15.36,0 23.424,-7.488 8.256,-7.296 8.256,-20.16 v -10.368 q -4.032,7.488 -13.632,12.864 -9.408,5.376 -19.968,5.376 -13.248,0 -24.192,-6.72 -10.944,-6.72 -17.28,-18.624 -6.144,-12.096 -6.144,-27.072 0,-14.976 6.144,-26.88 6.336,-12.096 17.088,-18.816 10.944,-6.72 24,-6.72 10.56,0 19.584,4.8 9.216,4.8 14.4,11.712 v -6.144 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 z m -40.512,86.976 q 9.408,0 16.704,-4.416 7.296,-4.608 11.328,-12.672 4.224,-8.256 4.224,-18.432 0,-10.176 -4.224,-18.24 -4.032,-8.064 -11.328,-12.672 -7.296,-4.608 -16.704,-4.608 -9.216,0 -16.512,4.608 -7.296,4.608 -11.52,12.864 -4.032,8.064 -4.032,18.048 0,9.984 4.032,18.24 4.224,8.064 11.52,12.672 7.296,4.608 16.512,4.608 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path832" />
<path
d="m 915.17075,181.69233 q -0.192,3.456 -2.88,5.952 -2.688,2.304 -6.336,2.304 h -67.584 q 1.344,14.016 10.56,22.464 9.408,8.448 22.848,8.448 9.216,0 14.976,-2.688 5.76,-2.688 10.176,-6.912 2.88,-1.728 5.568,-1.728 3.264,0 5.376,2.304 2.304,2.304 2.304,5.376 0,4.032 -3.84,7.296 -5.568,5.568 -14.784,9.408 -9.216,3.84 -18.816,3.84 -15.552,0 -27.456,-6.528 -11.712,-6.528 -18.24,-18.24 -6.336,-11.712 -6.336,-26.496 0,-16.128 6.528,-28.224 6.72,-12.288 17.472,-18.816 10.944,-6.528 23.424,-6.528 12.288,0 23.04,6.336 10.752,6.336 17.28,17.472 6.528,11.136 6.72,24.96 z m -47.04,-31.872 q -10.752,0 -18.624,6.144 -7.872,5.952 -10.368,18.624 h 56.64 v -1.536 q -0.96,-10.176 -9.216,-16.704 -8.064,-6.528 -18.432,-6.528 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path834" />
<path
d="m 986.84675,133.50033 q 13.056,0 23.61605,6.72 10.5599,6.528 16.512,18.432 6.144,11.904 6.144,26.88 0,14.976 -6.144,26.88 -5.9521,11.712 -16.512,18.432 -10.56005,6.72 -23.23205,6.72 -9.984,0 -18.624,-4.416 -8.64,-4.416 -14.016,-10.752 v 42.624 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.528,-2.688 -2.496,-2.496 -2.496,-6.528 v -120.768 q 0,-4.032 2.496,-6.72 2.496,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.72 v 5.568 q 4.608,-6.72 13.44,-11.52 8.832,-4.8 18.816,-4.8 z m -2.112,87.168 q 8.832,0 15.93595,-4.608 7.1041,-4.608 10.944,-12.48 4.032,-8.064 4.032,-18.048 0,-9.984 -4.032,-17.856 -3.8399,-8.064 -10.944,-12.672 -7.10395,-4.608 -15.93595,-4.608 -9.024,0 -16.128,4.608 -7.104,4.416 -11.136,12.48 -3.84,8.064 -3.84,18.048 0,9.984 3.84,18.048 4.032,8.064 11.136,12.672 7.104,4.416 16.128,4.416 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path836" />
<path
d="m 1104.2128,132.92433 q 4.9919,0 8.64,2.688 3.648,2.496 3.648,6.336 0,4.608 -2.496,7.104 -2.304,2.304 -5.76,2.304 -1.728,0 -5.184,-1.152 -4.032,-1.344 -6.336,-1.344 -5.952,0 -11.712,4.224 -5.568,4.032 -9.216,11.328 -3.456,7.104 -3.456,15.936 v 46.272 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -4.032,0 -6.528,-2.496 -2.496,-2.688 -2.496,-6.72 v -82.176 q 0,-3.84 2.496,-6.528 2.688,-2.688 6.528,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 v 9.792 q 4.224,-9.408 12.672,-15.168 8.448,-5.952 19.2,-6.144 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path838" />
<path
d="m 1150.5058,226.62033 q 0,3.84 -2.688,6.528 -2.496,2.688 -6.528,2.688 -3.84,0 -6.5281,-2.688 -2.4959,-2.688 -2.4959,-6.528 v -83.136 q 0,-3.84 2.4959,-6.528 2.6881,-2.688 6.5281,-2.688 4.032,0 6.528,2.688 2.688,2.688 2.688,6.528 z m -9.216,-105.024 q -5.568,0 -8.064,-1.92 -2.304,-2.112 -2.304,-6.528 v -3.072 q 0,-4.608 2.496,-6.528 2.688,-1.92 8.064,-1.92 5.3759,0 7.68,2.112 2.496,1.92 2.496,6.336 v 3.072 q 0,4.608 -2.496,6.528 -2.496,1.92 -7.872,1.92 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path840" />
<path
d="m 1220.5018,132.92433 q 15.168,0 25.152,4.8 10.1759,4.8 10.1759,12.288 0,3.264 -2.1119,5.952 -2.112,2.496 -5.376,2.496 -2.496,0 -4.0321,-0.768 -1.3439,-0.768 -3.8399,-2.496 -1.152,-1.152 -3.648,-2.688 -2.304,-1.152 -6.528,-1.92 -4.224,-0.768 -7.68,-0.768 -9.984,0 -17.664,4.608 -7.68,4.608 -11.904,12.864 -4.224,8.064 -4.224,18.048 0,10.176 4.032,18.24 4.224,8.064 11.712,12.672 7.488,4.608 17.088,4.608 9.984,0 16.128,-3.072 1.344,-0.768 3.648,-2.496 1.92,-1.536 3.264,-2.304 1.536,-0.768 3.648,-0.768 3.84,0 5.952,2.496 2.304,2.304 2.304,6.144 0,4.032 -5.184,8.064 -4.992,3.84 -13.632,6.336 -8.448,2.496 -18.24,2.496 -14.592,0 -25.728,-6.72 -11.136,-6.912 -17.28,-18.816 -5.952,-12.096 -5.952,-26.88 0,-14.784 6.336,-26.688 6.336,-12.096 17.664,-18.816 11.328,-6.912 25.92,-6.912 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path842" />
<path
d="m 1363.6708,181.69233 q -0.192,3.456 -2.88,5.952 -2.688,2.304 -6.3361,2.304 h -67.5839 q 1.344,14.016 10.56,22.464 9.4079,8.448 22.848,8.448 9.216,0 14.976,-2.688 5.76,-2.688 10.176,-6.912 2.88,-1.728 5.568,-1.728 3.264,0 5.376,2.304 2.304,2.304 2.304,5.376 0,4.032 -3.8401,7.296 -5.5679,5.568 -14.7839,9.408 -9.2161,3.84 -18.816,3.84 -15.552,0 -27.456,-6.528 -11.712,-6.528 -18.24,-18.24 -6.336,-11.712 -6.336,-26.496 0,-16.128 6.528,-28.224 6.72,-12.288 17.472,-18.816 10.944,-6.528 23.424,-6.528 12.288,0 23.04,6.336 10.752,6.336 17.28,17.472 6.528,11.136 6.72,24.96 z m -47.04,-31.872 q -10.752,0 -18.624,6.144 -7.872,5.952 -10.368,18.624 h 56.64 v -1.536 q -0.96,-10.176 -9.216,-16.704 -8.064,-6.528 -18.432,-6.528 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:192px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Medium'"
id="path844" />
</g>
<g aria-label="POWERED BY" transform="matrix(0.93750004,0,0,0.93750004,231.12524,266.21949)"
style="font-style:normal;font-weight:normal;font-size:25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="flowRoot825-6">
<path
d="m 331.92042,135.56966 q 5.44,0 10.34666,3.30667 4.90667,3.2 7.89334,8.74667 2.98666,5.44 2.98666,11.94666 0,6.4 -2.98666,11.94667 -2.98667,5.54666 -7.89334,8.85333 -4.90666,3.2 -10.34666,3.2 h -18.56 v 20.16 q 0,2.88 -1.70667,4.69333 -1.70667,1.81334 -4.48,1.81334 -2.66667,0 -4.37333,-1.81334 -1.70667,-1.92 -1.70667,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69334,-1.92 z m 0,35.84 q 2.02666,0 3.94666,-1.70667 2.02667,-1.70666 3.2,-4.37333 1.28,-2.77333 1.28,-5.76 0,-2.98667 -1.28,-5.65333 -1.17333,-2.77333 -3.2,-4.37333 -1.92,-1.70667 -3.94666,-1.70667 h -18.56 v 23.57333 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path883" />
<path
d="m 434.16208,172.90299 q 0,10.56 -4.69333,19.41334 -4.69333,8.74666 -13.01333,13.86666 -8.21334,5.12 -18.56,5.12 -10.34667,0 -18.66667,-5.12 -8.21333,-5.12 -12.90667,-13.86666 -4.58666,-8.85334 -4.58666,-19.41334 0,-10.56 4.58666,-19.30666 4.69334,-8.85333 12.90667,-13.97333 8.32,-5.12 18.66667,-5.12 10.34666,0 18.56,5.12 8.32,5.12 13.01333,13.97333 4.69333,8.74666 4.69333,19.30666 z m -13.86666,0 q 0,-7.14666 -2.88,-12.90666 -2.88,-5.86667 -8,-9.28 -5.12,-3.41333 -11.52,-3.41333 -6.50667,0 -11.62667,3.41333 -5.01333,3.30666 -7.89333,9.17333 -2.77334,5.86667 -2.77334,13.01333 0,7.14667 2.77334,13.01334 2.88,5.86666 7.89333,9.28 5.12,3.30666 11.62667,3.30666 6.4,0 11.52,-3.41333 5.12,-3.41333 8,-9.17333 2.88,-5.86667 2.88,-13.01334 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path885" />
<path
d="m 529.83207,135.24966 q 2.56,0 4.69333,2.02667 2.24,1.92 2.24,4.90667 0,0.96 -0.32,2.13333 l -21.01333,61.86666 q -0.64,1.81334 -2.24,2.88 -1.6,1.06667 -3.52,1.17334 -1.92,0 -3.62667,-1.06667 -1.70666,-1.06667 -2.66666,-3.09333 l -15.14667,-34.45334 -15.25333,34.45334 q -0.96,2.02666 -2.66667,3.09333 -1.70666,1.06667 -3.62666,1.06667 -1.92,-0.10667 -3.52,-1.17334 -1.6,-1.06666 -2.24,-2.88 l -21.01334,-61.86666 q -0.32,-1.17333 -0.32,-2.13333 0,-2.98667 2.13334,-4.90667 2.24,-2.02667 4.90666,-2.02667 2.13334,0 3.84,1.17334 1.70667,1.06666 2.34667,2.98666 l 15.89333,48.21333 13.86667,-33.28 q 0.85333,-1.91999 2.45333,-2.98666 1.6,-1.17333 3.62667,-1.06667 2.02667,-0.10666 3.52,1.06667 1.6,1.06667 2.45333,2.98666 l 13.12,32.96 15.78667,-47.89333 q 0.64,-1.92 2.34666,-2.98666 1.81334,-1.17334 3.94667,-1.17334 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path887" />
<path
d="m 588.75041,197.96966 q 2.77333,0 4.58666,1.92 1.92,1.81333 1.92,4.26667 0,2.66666 -1.92,4.37333 -1.81333,1.70667 -4.58666,1.70667 h -35.73334 q -2.77333,0 -4.69333,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69333,-1.92 h 35.73334 q 2.77333,0 4.58666,1.81334 1.92,1.70666 1.92,4.48 0,2.66666 -1.81333,4.37333 -1.81333,1.6 -4.69333,1.6 h -28.90667 v 18.13333 h 24.10667 q 2.77333,0 4.58666,1.81333 1.92,1.70667 1.92,4.48 0,2.66667 -1.81333,4.37334 -1.81333,1.6 -4.69333,1.6 h -24.10667 v 19.73333 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path889" />
<path
d="m 666.11206,199.78299 q 1.38667,0.85334 2.13333,2.24 0.85334,1.38667 0.85334,2.88 0,1.92 -1.28,3.52 -1.6,1.92 -4.90667,1.92 -2.56,0 -4.69333,-1.17333 -7.68,-4.37333 -7.68,-17.81333 0,-3.84 -2.56,-6.08 -2.45333,-2.24 -7.14667,-2.24 H 620.8854 v 20.69333 q 0,2.88 -1.6,4.69333 -1.49334,1.81334 -4.05334,1.81334 -3.09333,0 -5.44,-1.81334 -2.24,-1.92 -2.24,-4.69333 v -61.65333 q 0,-2.77333 1.81334,-4.58667 1.92,-1.92 4.69333,-1.92 h 30.72 q 5.54667,0 10.45333,2.98667 4.90667,2.98667 7.78667,8.21333 2.98666,5.22667 2.98666,11.73333 0,5.33334 -2.88,10.45334 -2.88,5.01333 -7.46666,8 6.72,4.69333 7.36,12.58666 0.32,1.70667 0.32,3.30667 0.42666,3.30667 0.85333,4.8 0.42667,1.38667 1.92,2.13333 z M 644.2454,172.04966 q 1.92,0 3.73333,-1.81333 1.81333,-1.81334 2.98667,-4.8 1.17333,-3.09334 1.17333,-6.61334 0,-2.98666 -1.17333,-5.43999 -1.17334,-2.56 -2.98667,-4.05334 -1.81333,-1.49333 -3.73333,-1.49333 h -23.36 v 24.21333 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path891" />
<path
d="m 723.12541,197.96966 q 2.77333,0 4.58666,1.92 1.92,1.81333 1.92,4.26667 0,2.66666 -1.92,4.37333 -1.81333,1.70667 -4.58666,1.70667 h -35.73334 q -2.77333,0 -4.69333,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69333,-1.92 h 35.73334 q 2.77333,0 4.58666,1.81334 1.92,1.70666 1.92,4.48 0,2.66666 -1.81333,4.37333 -1.81333,1.6 -4.69333,1.6 h -28.90667 v 18.13333 h 24.10667 q 2.77333,0 4.58666,1.81333 1.92,1.70667 1.92,4.48 0,2.66667 -1.81333,4.37334 -1.81333,1.6 -4.69333,1.6 h -24.10667 v 19.73333 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path893" />
<path
d="m 773.92706,135.56966 q 10.02667,0 17.17333,5.01334 7.25334,4.90666 10.98667,13.43999 3.84,8.42667 3.84,18.88 0,10.45334 -3.84,18.98667 -3.73333,8.42667 -10.98667,13.44 -7.14666,4.90667 -17.17333,4.90667 h -25.49333 q -2.77333,0 -4.69333,-1.81334 -1.81334,-1.92 -1.81334,-4.69333 v -61.65333 q 0,-2.77333 1.81334,-4.58667 1.92,-1.92 4.69333,-1.92 z m -1.06666,62.4 q 9.6,0 14.4,-7.04 4.79999,-7.14667 4.79999,-18.02667 0,-10.88 -4.90666,-17.92 -4.8,-7.14666 -14.29333,-7.14666 h -17.6 v 50.13333 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path895" />
<path
d="m 892.76875,168.84966 q 5.65333,2.24 9.17333,6.82667 3.62667,4.58666 3.62667,11.84 0,12.69333 -7.25333,17.70666 -7.25334,5.01334 -17.28,5.01334 h -26.56 q -2.77334,0 -4.69334,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -61.65333 q 0,-2.77333 1.81333,-4.58667 1.92,-1.92 4.69334,-1.92 h 26.88 q 20.26666,0 20.26666,18.98667 0,4.8 -2.34666,8.53333 -2.24,3.62667 -6.50667,5.76 z m -5.01333,-11.94667 q 0,-4.37333 -2.24,-6.50666 -2.13334,-2.24 -6.08,-2.24 h -17.6 v 16.64 h 17.92 q 3.2,0 5.54666,-2.13334 2.45334,-2.13333 2.45334,-5.76 z m -6.72,41.06667 q 5.01333,0 7.78666,-2.66667 2.88,-2.66666 2.88,-7.78666 0,-6.29334 -3.30666,-8.21334 -3.30667,-1.92 -8.10667,-1.92 h -18.45333 v 20.58667 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path897" />
<path
d="m 969.23371,141.863 q 0,2.13333 -1.17334,3.94666 l -22.29333,31.89333 v 26.02667 q 0,2.77333 -1.81333,4.69333 -1.81333,1.81334 -4.37333,1.81334 -2.66667,0 -4.58667,-1.81334 -1.81333,-1.92 -1.81333,-4.69333 v -27.52 l -22.18667,-29.44 q -1.92,-2.56 -1.92,-5.01333 0,-2.77333 2.13333,-4.58667 2.24,-1.92 4.69334,-1.92 2.98666,0 5.22666,2.98667 l 18.77334,25.92 17.59999,-25.70667 q 2.24,-3.2 5.33334,-3.2 2.56,0 4.48,1.92 1.92,1.92 1.92,4.69334 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:106.66666412px;font-family:Quicksand;-inkscape-font-specification:'Quicksand Bold'"
id="path899" />
</g>
</g>
<g inkscape:groupmode="layer" id="layer2" inkscape:label="Layer 3"
transform="translate(-27.815468,21.198496)" />
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1"
transform="translate(-220.97188,-392.31605)">
<path inkscape:connector-curvature="0" style="fill:#000016;fill-opacity:1"
d="m 467.91504,565.96351 -11.49602,-6.03301 10.02002,-5.23601 c 3.41201,-1.785 3.41501,-4.70701 0.01,-6.49601 l -10.76202,-5.64801 10.75602,-5.62101 c 3.41201,-1.78501 3.41501,-4.70701 0.01,-6.49602 L 380.3709,485.25335 c -3.40901,-1.789 -8.99702,-1.809 -12.41802,-0.041 L 223.53563,559.8065 c -3.422,1.766 -3.418,4.65001 0.01,6.41001 l 11.01802,5.66202 -11.02302,5.69301 c -3.422,1.766 -3.418,4.65001 0.01,6.41001 l 11.75602,6.04101 -10.28901,5.31401 c -3.42201,1.76801 -3.41801,4.65201 0.01,6.41201 l 88.01014,45.22309 c 3.42601,1.76001 9.02002,1.74001 12.43002,-0.043 l 142.46424,-74.46914 c 3.40901,-1.78301 3.41201,-4.70701 0,-6.49602 z m -93.03415,-58.62311 -21.59604,24.90005 c -1.08,1.246 -0.764,2.883 0.703,3.637 l 17.18203,8.82802 c 1.467,0.754 1.469,1.99201 0,2.74801 l -53.92109,27.85005 c -1.467,0.758 -1.781,0.357 -0.699,-0.889 l 21.59404,-24.90005 c 1.082,-1.246 0.766,-2.885 -0.70101,-3.63901 l -17.18202,-8.82801 c -1.46701,-0.75401 -1.46901,-1.99001 0,-2.74801 l 53.92209,-27.85005 c 1.465,-0.75601 1.78,-0.35501 0.699,0.891 z"
id="path11" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -6,15 +6,15 @@ apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
android {
compileSdkVersion 30
compileSdkVersion 31
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 30
versionCode 37
versionName "0.4.3"
targetSdkVersion 31
versionCode 66
versionName "1.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -77,106 +77,138 @@ android {
if (goingelectricKey != null) {
variant.resValue "string", "goingelectric_key", goingelectricKey
}
def openchargemapKey = env.OPENCHARGEMAP_API_KEY ?: project.findProperty("OPENCHARGEMAP_API_KEY")
if (openchargemapKey == null && project.hasProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED")) {
openchargemapKey = decode(project.findProperty("OPENCHARGEMAP_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (openchargemapKey != null) {
variant.resValue "string", "openchargemap_key", openchargemapKey
}
def googleMapsKey = env.GOOGLE_MAPS_API_KEY ?: project.findProperty("GOOGLE_MAPS_API_KEY")
if (googleMapsKey != null && variant.flavorName == 'google') {
variant.resValue "string", "google_maps_key", googleMapsKey
}
def mapboxKey = env.MAPBOX_API_KEY ?: project.findProperty("MAPBOX_API_KEY")
if (mapboxKey == null && project.hasProperty("MAPBOX_API_KEY_ENCRYPTED")) {
mapboxKey = decode(project.findProperty("MAPBOX_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (mapboxKey != null) {
variant.resValue "string", "mapbox_key", mapboxKey
}
def chargepriceKey = env.CHARGEPRICE_API_KEY ?: project.findProperty("CHARGEPRICE_API_KEY")
if (chargepriceKey == null && project.hasProperty("CHARGEPRICE_API_KEY_ENCRYPTED")) {
chargepriceKey = decode(project.findProperty("CHARGEPRICE_API_KEY_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (chargepriceKey != null) {
variant.resValue "string", "chargeprice_key", chargepriceKey
}
}
lintOptions {
disable 'NullSafeMutableLiveData'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.3.6"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.core:core:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.browser:browser:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.browser:browser:1.4.0'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
implementation 'com.squareup.moshi:moshi-kotlin:1.12.0'
implementation 'com.squareup.moshi:moshi-adapters:1.12.0'
implementation 'moe.banana:moshi-jsonapi:3.5.0'
implementation 'moe.banana:moshi-jsonapi-retrofit-converter:3.5.0'
implementation 'io.coil-kt:coil:1.1.0'
implementation 'com.github.MikeOrtiz:TouchImageView:3.0.3'
implementation 'com.github.johan12345:StfalconImageViewer:5082ebd392'
implementation "com.mikepenz:aboutlibraries-core:$about_libs_version"
implementation "com.mikepenz:aboutlibraries:$about_libs_version"
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks:bimap:1.0.2'
implementation 'com.airbnb.android:lottie:4.1.0'
implementation 'io.michaelrocks.bimap:bimap:1.1.0'
implementation 'com.mapzen.android:lost:3.0.2'
implementation 'com.google.guava:guava:29.0-android'
implementation 'com.github.pengrad:mapscaleview:1.6.0'
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
// Android Auto
googleImplementation 'androidx.car.app:app:1.1.0-rc01'
googleImplementation 'androidx.car.app:app-projected:1.1.0-rc01'
// AnyMaps
def anyMapsVersion = '7753eeb7b0'
def anyMapsVersion = '751daec281'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
googleImplementation 'com.google.android.gms:play-services-maps:18.0.0'
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
// Google Maps v3 Beta
googleImplementation 'com.google.android.libraries.maps:maps:3.1.0-beta'
googleImplementation name:'places-maps-sdk-3.1.0-beta', ext:'aar'
googleImplementation 'com.android.volley:volley:1.1.1'
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
googleImplementation 'com.google.android.gms:play-services-location:17.1.0'
googleImplementation 'com.google.android.gms:play-services-tasks:17.2.0'
googleImplementation 'com.google.auto.value:auto-value-annotations:1.6.3'
googleImplementation 'com.google.code.gson:gson:2.8.6'
googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5'
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Google Places
implementation 'com.google.android.libraries.places:places:2.5.0'
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1'
// Mapbox places (autocomplete)
implementation('com.mapbox.mapboxsdk:mapbox-android-plugin-places-v9:0.12.0') {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
}
// Mapbox Geocoding
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
// navigation library
def nav_version = "2.3.2"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// viewmodel library
def lifecycle_version = "2.2.0"
def lifecycle_version = "2.4.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// room library
def room_version = "2.2.6"
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "3.0.2"
def billing_version = "4.0.0"
googleImplementation "com.android.billingclient:billing:$billing_version"
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
// ACRA (crash reporting)
def acraVersion = "5.8.4"
implementation("ch.acra:acra-mail:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
implementation 'com.facebook.stetho:stetho:1.5.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.5.1'
testImplementation 'junit:junit:4.13'
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.0"
//noinspection GradleDependency
testImplementation 'org.json:json:20080701'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.9.2"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.12.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
}
private static String decode(String s, String key) {
return new String(xorWithKey(s.decodeBase64(), key.getBytes()), "UTF-8");
}
private static byte[] xorWithKey(byte[] a, byte[] key) {
byte[] out = new byte[a.length];
for (int i = 0; i < a.length; i++) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}

View File

Binary file not shown.

View File

@@ -0,0 +1,107 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="198.3471"
android:viewportHeight="198.3471">
<group
android:translateX="3.1735537"
android:translateY="3.1735537">
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,74.3h-7"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M106.2,60.3c0,0 -17.5,0 -17.5,0"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:pathData="M93.9,79.5L88.7,79.5"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M94,79v16.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#212121"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,60.3L99.2,74.3"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<group>
<clip-path android:pathData="M122.5,57.1c-5.8,-7.7 -15.2,-12.4 -24.7,-12.9H94c-8.1,0.5 -15.9,3.9 -21.7,9.6c-4.3,4.3 -7.4,9.7 -8.6,15.7c-1.5,7.5 -0.1,15.4 3.5,22.1c2.3,4.5 5.2,8.6 8.1,12.8c4.9,6.8 9.8,13.7 13.2,21.3c3.1,6.5 4.9,13.6 5.6,20.8c0.4,0.6 1.1,1 1.7,1.5c2.6,-1 2,-4 2.5,-6.2c1.9,-12.8 8.7,-24.2 16.1,-34.6c6.6,-9.2 14.1,-19 14.4,-30.8V74C128.5,67.9 126.3,61.9 122.5,57.1zM106.2,74.3l-12.2,21V79.5h-5.2V60.3h17.5l-7,14H106.2z" />
<path
android:strokeWidth="1"
android:pathData="M106.2,74.3L93.9,95.2"
android:strokeAlpha="0.2"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:fillAlpha="0.2"
android:strokeLineCap="round" />
</group>
<path
android:pathData="M67.6,120.6L65.7,104l-2.9,0.3l1.9,16.6L67.6,120.6zM77.9,119.4l-1.9,-16.6l-2.9,0.3l1.9,16.6L77.9,119.4z"
android:fillColor="#808080" />
<path
android:pathData="M83.3,142c-0.9,1.1 -1.6,1.8 -1.7,1.9c-2.6,2.1 -4.7,2.7 -6.4,1.9c-3,-1.5 -2.8,-7.1 -2.7,-7.7l2.1,0.1c-0.1,1.6 0.2,5 1.6,5.7c0.8,0.4 2.2,-0.1 4,-1.6l0,0c0,0 5.8,-5.8 4.6,-10.4c-1.4,-5.5 5,-13.4 7.1,-16.1l0.3,-0.3l1.7,1.3l-0.3,0.4c-6.5,8 -7.2,12.1 -6.7,14.2C87.9,135.4 85.2,139.7 83.3,142z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M61.2,120.4l0.8,6.8l6.3,4.2l8.5,-0.9l5.2,-5.5l-0.8,-6.8L61.2,120.4z"
android:fillColor="#9e9e9e" />
<path
android:pathData="M76.7,130.5l-8.5,0.9l1.8,7.5l6.7,-0.8L76.7,130.5L76.7,130.5zM82.8,112.5l0.7,6.2l-24.4,2.8l-0.7,-6.2L82.8,112.5z"
android:fillColor="#666666" />
<path
android:pathData="M101.9,44.1c-17.5,0 -31.7,14.2 -31.7,31.7c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5C133.6,58.2 119.4,44.1 101.9,44.1z"
android:fillColor="#737373" />
<path
android:pathData="M101.9,44.8c17.4,0 31.5,14 31.7,31.3c0,-0.1 0,-0.2 0,-0.3c0,-17.5 -14.2,-31.7 -31.7,-31.7S70.2,58.2 70.2,75.8c0,0.1 0,0.2 0,0.3C70.4,58.8 84.5,44.8 101.9,44.8L101.9,44.8z"
android:fillColor="#FFFFFF"
android:fillAlpha="0.2" />
<path
android:pathData="M103.8,145.5c-0.1,1 -0.9,1.7 -1.9,1.7s-1.8,-0.7 -1.9,-1.7c-3.1,-34 -29.6,-46.5 -29.8,-70.1c0,0.2 0,0.3 0,0.5c0,23.9 26.7,36.4 29.9,70.5c0.1,1 0.9,1.7 1.9,1.7s1.8,-0.7 1.9,-1.7c3.2,-34.1 29.9,-46.6 29.9,-70.5c0,-0.2 0,-0.3 0,-0.5C133.4,99 106.9,111.5 103.8,145.5L103.8,145.5z"
android:fillColor="#303030"
android:fillAlpha="0.2" />
<path
android:fillColor="#FF000000"
android:pathData="M94.6,60.3v19.2h5.2v15.7l12.2,-21h-7l7,-14C112.1,60.3 94.6,60.3 94.6,60.3z"
android:strokeAlpha="0.45"
android:fillAlpha="0.45" />
</group>
</vector>

View File

@@ -1,49 +1,5 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.content.Intent
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.mapbox.geojson.BoundingBox
import com.mapbox.geojson.Point
import com.mapbox.mapboxsdk.plugins.places.autocomplete.PlaceAutocomplete
import com.mapbox.mapboxsdk.plugins.places.autocomplete.model.PlaceOptions
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE
import net.vonforst.evmap.viewmodel.PlaceWithBounds
fun launchAutocomplete(fragment: Fragment) {
val placeOptions = PlaceOptions.builder()
.build(PlaceOptions.MODE_CARDS)
val intent = PlaceAutocomplete.IntentBuilder()
.accessToken(fragment.getString(R.string.mapbox_key))
.placeOptions(placeOptions)
.build(fragment.requireActivity())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
// show keyboard
val imm = fragment.requireContext()
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, 0)
}
fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? {
val place = PlaceAutocomplete.getPlace(intent) ?: return null
val bbox = place.bbox()?.toLatLngBounds()
val center = place.center()!!.toLatLng()
return PlaceWithBounds(center, bbox)
}
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
return LatLngBounds(
southwest().toLatLng(),
northeast().toLatLng()
)
}
private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude())
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))

View File

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

View File

@@ -0,0 +1,16 @@
package net.vonforst.evmap.fragment
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}

View File

@@ -3,6 +3,12 @@
<string-array name="pref_map_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string name="donate_paypal">Mit PayPal spenden</string>
</resources>

View File

@@ -6,6 +6,13 @@
<string-array name="pref_map_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>mapbox</item>
</string-array>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="pref_map_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
<string name="donate_paypal">Donate with PayPal</string>

View File

@@ -1,10 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
<uses-sdk tools:overrideLibrary="androidx.car.app,androidx.car.app.projected" />
<queries>
<package android:name="com.google.android.projection.gearhead" />
</queries>
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.theme"
android:resource="@style/CarAppTheme" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<service
android:name=".auto.CarAppService"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action
android:name="androidx.car.app.CarAppService"
android:category="androidx.car.app.category.CHARGING" />
</intent-filter>
</service>
<service
android:name=".auto.CarLocationService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View File

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

View File

@@ -0,0 +1,147 @@
package net.vonforst.evmap.auto
import android.content.*
import android.content.pm.ApplicationInfo
import android.location.Location
import android.os.IBinder
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.validation.HostValidator
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import net.vonforst.evmap.utils.checkAnyLocationPermission
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
class CarAppService : androidx.car.app.CarAppService() {
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}
}
override fun onCreateSession(): Session {
return EVMapSession(this)
}
}
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
field = value
location?.let { value?.updateLocation(it) }
}
private var location: Location? = null
private var locationService: CarLocationService? = null
private val hardwareMan: CarHardwareManager by lazy {
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ibinder: IBinder) {
val binder: CarLocationService.LocalBinder = ibinder as CarLocationService.LocalBinder
locationService = binder.service
locationService?.requestLocationUpdates()
}
override fun onServiceDisconnected(name: ComponentName?) {
locationService = null
}
}
private var serviceBound = false
init {
lifecycle.addObserver(this)
}
override fun onCreateScreen(intent: Intent): Screen {
return WelcomeScreen(carContext, this)
}
fun locationPermissionGranted() = carContext.checkAnyLocationPermission()
private val locationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val location = intent.getParcelableExtra(CarLocationService.EXTRA_LOCATION) as Location?
updateLocation(location)
}
}
private fun updateLocation(location: Location?) {
val mapScreen = mapScreen
if (location != null && mapScreen != null) {
mapScreen.updateLocation(location)
}
this.location = location
}
private fun onCarHardwareLocationReceived(loc: CarHardwareLocation) {
updateLocation(loc.location.value)
// we successfully received a location from the car hardware,
// so we don't need the smartphone location anymore.
unbindLocationService()
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun bindLocationService() {
if (!locationPermissionGranted()) return
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL,
exec,
::onCarHardwareLocationReceived
)
}
serviceBound = cas.bindService(
Intent(cas, CarLocationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun onStop() {
if (supportsCarApiLevel3(carContext)) {
hardwareMan.carSensors.removeCarHardwareLocationListener(::onCarHardwareLocationReceived)
}
unbindLocationService()
}
private fun unbindLocationService() {
locationService?.removeLocationUpdates()
if (serviceBound) {
cas.unbindService(serviceConnection)
serviceBound = false
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun registerBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).registerReceiver(
locationReceiver,
IntentFilter(CarLocationService.ACTION_BROADCAST)
);
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun unregisterBroadcastReceiver() {
LocalBroadcastManager.getInstance(cas).unregisterReceiver(locationReceiver)
}
}

View File

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

View File

@@ -0,0 +1,295 @@
package net.vonforst.evmap.auto
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.Model
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonBuffer
import moe.banana.jsonapi2.ResourceIdentifier
import net.vonforst.evmap.*
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import java.io.IOException
class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(carContext.getString(R.string.chargeprice_key))
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private val maxRows = 6
private var errorMessage: String? = null
private val batteryRange = listOf(20.0, 80.0)
override fun onGetTemplate(): Template {
if (prices == null) loadData()
return ListTemplate.Builder().apply {
setTitle(
carContext.getString(
R.string.chargeprice_battery_range,
batteryRange[0],
batteryRange[1]
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
)
setHeaderAction(Action.BACK)
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
setSingleList(ItemList.Builder().apply {
setNoItemsMessage(
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.take(maxRows)?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build())
}
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
).setOnClickListener {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ContextCompat.getColor(
carContext,
R.color.colorPrimary
)
)
.build()
)
.build().intent
intent.data =
Uri.parse("https://www.chargeprice.app/?poi_id=${charger.id}&poi_source=${getDataAdapter()}")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
} catch (e: ActivityNotFoundException) {
CarToast.makeText(
carContext,
R.string.no_browser_app_found,
CarToast.LENGTH_LONG
).show()
}
}.build()
).build()
)
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
} else {
return price.tariffName
}
}
private fun formatPrice(price: ChargePrice): String {
val totalPrice = carContext.getString(
R.string.charge_price_format,
price.chargepointPrices.first().price,
currency(price.currency)
)
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
carContext.getString(
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
R.string.charge_price_kwh_format
} else {
R.string.charge_price_average_format
},
price.chargepointPrices.get(0).price / meta!!.energy,
currency(price.currency)
)
} else null
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
price.formatMonthlyFees(carContext)
} else null
var text = totalPrice
if (kwhPrice != null && monthlyFees != null) {
text += " ($kwhPrice, $monthlyFees)"
} else if (kwhPrice != null) {
text += " ($kwhPrice)"
} else if (monthlyFees != null) {
text += " ($monthlyFees)"
}
return text
}
private fun loadData() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
val hardwareMan =
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
hardwareMan.carInfo.fetchModel(exec) { model ->
loadPrices(model)
}
} else {
loadPrices(null)
}
}
private fun loadPrices(model: Model?) {
val dataAdapter = getDataAdapter() ?: return
val manufacturer = model?.manufacturer?.value
val modelName = model?.name?.value
lifecycleScope.launch {
try {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
return@launch
} else if (vehicles.size > 1) {
if (manufacturer != null && modelName != null) {
vehicles = vehicles.filter {
it.brand == manufacturer && it.name.startsWith(modelName)
}
if (vehicles.isEmpty()) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
return@launch
} else if (vehicles.size > 1) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
return@launch
}
} else {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
return@launch
}
}
val car = vehicles[0]
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
val result = api.getChargePrices(ChargepriceRequest().apply {
this.dataAdapter = dataAdapter
station = cpStation
vehicle = HasOne(car)
tariffs = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
HasMany<ChargepriceTariff>(*myTariffs.map {
ResourceIdentifier(
"tariff",
it
)
}.toTypedArray()).apply {
meta = JsonBuffer.create(
ChargepriceApi.moshi.adapter(ChargepriceRequestTariffMeta::class.java),
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS)
)
}
} else null
options = ChargepriceOptions(
batteryRange = batteryRange,
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency
)
}, ChargepriceApi.getChargepriceLanguage())
val myTariffs = prefs.chargepriceMyTariffs
// choose the highest power chargepoint compatible with the car
val chargepoint = cpStation.chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull { it.power }
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
meta =
(result.meta.get<ChargepriceMeta>(ChargepriceApi.moshi.adapter(ChargepriceMeta::class.java)) as ChargepriceMeta).chargePoints.filterIndexed { i, cp ->
charger.chargepointsMerged[i].type in car.compatibleEvmapConnectors
}.maxByOrNull {
it.power
}
prices = result.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.clone().apply {
chargepointPrices = filteredPrices
}
}
}.filterNotNull()
.sortedBy { it.chargepointPrices.first().price }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariff?.get()?.id in myTariffs
}
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
}
}
}
private fun getDataAdapter(): String? = when (charger.dataSource) {
"goingelectric" -> ChargepriceApi.DATA_SOURCE_GOINGELECTRIC
"openchargemap" -> ChargepriceApi.DATA_SOURCE_OPENCHARGEMAP
else -> null
}
}

View File

@@ -0,0 +1,237 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.getReferenceData
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val imageSize = 128 // images should be 128dp according to docs
private val iconGen =
ChargerIconGenerator(carContext, null, oversize = 1.4f, height = imageSize)
init {
referenceData.observe(this) {
loadCharger()
}
}
override fun onGetTemplate(): Template {
if (charger == null) loadCharger()
return PaneTemplate.Builder(
Pane.Builder().apply {
charger?.let { charger ->
addRow(Row.Builder().apply {
setTitle(charger.address.toString())
val icon = iconGen.getBitmap(
tint = getMarkerTint(charger),
fault = charger.faultReport != null,
multi = charger.isMulti()
)
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(icon)).build(),
Row.IMAGE_TYPE_LARGE
)
val chargepointsText = SpannableStringBuilder()
charger.chargepointsMerged.forEachIndexed { i, cp ->
if (i > 0) chargepointsText.append(" · ")
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext.stringProvider(),
cp.type
)
} ${cp.formatPower()}"
)
availability?.status?.get(cp)?.let { status ->
chargepointsText.append(
" (${availabilityText(status)}/${cp.count})",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
addText(chargepointsText)
}.build())
addRow(Row.Builder().apply {
photo?.let {
setImage(
CarIcon.Builder(IconCompat.createWithBitmap(photo)).build(),
Row.IMAGE_TYPE_LARGE
)
}
val operatorText = StringBuilder().apply {
charger.operator?.let { append(it) }
charger.network?.let {
if (isNotEmpty()) append(" · ")
append(it)
}
}.ifEmpty {
carContext.getString(R.string.unknown_operator)
}
setTitle(operatorText)
charger.cost?.let { addText(it.getStatusText(carContext, emoji = true)) }
charger.faultReport?.created?.let {
addText(
carContext.getString(
R.string.auto_fault_report_date,
it.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
)
)
}
/*val types = charger.chargepoints.map { it.type }.distinct()
if (types.size == 1) {
setImage(
CarIcon.of(IconCompat.createWithResource(carContext, iconForPlugType(types[0]))),
Row.IMAGE_TYPE_ICON)
}*/
}.build())
addAction(Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_navigation
)
).build()
)
.setTitle(carContext.getString(R.string.navigate))
.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))
.setOnClickListener {
screenManager.push(ChargepriceScreen(carContext, charger))
}
.build())
}
}
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, chargerSparse.id)
.putExtra(EXTRA_LAT, chargerSparse.coordinates.lat)
.putExtra(EXTRA_LON, chargerSparse.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
).build()
)
}.build()
}
private fun navigateToCharger(charger: ChargeLocation) {
val coord = charger.coordinates
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
)
carContext.startCarApp(intent)
}
private fun loadCharger() {
val referenceData = referenceData.value ?: return
lifecycleScope.launch {
val response = api.getChargepointDetail(referenceData, chargerSparse.id)
if (response.status == Status.SUCCESS) {
charger = response.data!!
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = photo.getUrl(size = size)
val request = ImageRequest.Builder(carContext).data(url).build()
this@ChargerDetailScreen.photo =
(carContext.imageLoader.execute(request).drawable as BitmapDrawable).bitmap
}
availability = charger?.let { getAvailability(it).data }
invalidate()
} else {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
package net.vonforst.evmap.auto
import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LiveData
import net.vonforst.evmap.R
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import kotlin.math.roundToInt
class FilterScreen(ctx: CarContext) : Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(ctx)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles(prefs.dataSource)
}
private val maxRows = 6
private val checkIcon =
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check)).build()
private val emptyIcon: CarIcon
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
emptyIcon = Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
).asCarIcon()
}
init {
filterProfiles.observe(this) {
invalidate()
}
}
override fun onGetTemplate(): Template {
val filterStatus =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
return ListTemplate.Builder().apply {
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), filterStatus))
} ?: setLoading(true)
setTitle(carContext.getString(R.string.menu_filter))
setHeaderAction(Action.BACK)
}.build()
}
private fun buildFilterProfilesList(
profiles: List<FilterProfile>,
filterStatus: Long
): ItemList {
return ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.no_filters))
if (FILTERS_DISABLED == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = FILTERS_DISABLED
screenManager.pop()
}
}.build())
profiles.forEach {
addItem(Row.Builder().apply {
val name =
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
setTitle(name)
if (it.id == filterStatus) {
setImage(checkIcon)
} else {
setImage(emptyIcon)
}
setOnClickListener {
prefs.filterStatus = it.id
screenManager.pop()
}
}.build())
}
setNoItemsMessage(carContext.getString(R.string.filterprofiles_empty_state))
}.build()
}
}

View File

@@ -0,0 +1,383 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.location.Location
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.lifecycleScope
import com.car2go.maps.model.LatLng
import kotlinx.coroutines.*
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_FAVORITES
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.filtersWithValue
import net.vonforst.evmap.viewmodel.getFilterValues
import net.vonforst.evmap.viewmodel.getFilters
import net.vonforst.evmap.viewmodel.getReferenceData
import java.io.IOException
import java.time.Duration
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.collections.set
import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites
*/
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
/* Updating map contents is disabled - if the user uses Chargeprice from the charger
detail screen, this already means 4 steps, after which the app would crash.
follow https://issuetracker.google.com/issues/176694222 for updates how to solve this. */
private val maxNumUpdates = 1
private var location: Location? = null
private var lastChargerUpdateLocation: Location? = null
private var lastDistanceUpdateTime: Instant? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val chargerUpdateThreshold = 2000 // meters
private val distanceUpdateThreshold = Duration.ofSeconds(15)
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST)
} else 6
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
}
private val filterValues = db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val filters = api.getFilters(referenceData, carContext.stringProvider())
private val filtersWithValue = filtersWithValue(filters, filterValues)
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private var energyLevel: EnergyLevel? = null
init {
filtersWithValue.observe(this) {
loadChargers()
}
}
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(
carContext.getString(
if (favorites) {
R.string.auto_favorites
} else {
R.string.auto_chargers_closeby
}
)
)
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
} ?: setLoading(true)
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
builder.setNoItemsMessage(
carContext.getString(
if (favorites) {
R.string.auto_no_favorites_found
} else {
R.string.auto_no_chargers_found
}
)
)
setItemList(builder.build())
} ?: setLoading(true)
setCurrentLocationEnabled(true)
setHeaderAction(Action.BACK)
if (!favorites) {
val filtersCount = filtersWithValue.value?.count {
!it.value.hasSameValueAs(it.filter.defaultValue())
}
setActionStrip(
ActionStrip.Builder()
.addAction(
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_filter
)
)
.setTint(if (filtersCount != null && filtersCount > 0) CarColor.SECONDARY else CarColor.DEFAULT)
.build()
)
.setOnClickListener {
screenManager.pushForResult(FilterScreen(carContext)) {
chargers = null
numUpdates = 0
filterStatus.value =
prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM || it == FILTERS_FAVORITES }
?: FILTERS_DISABLED
}
session.mapScreen = null
}
.build())
.build())
}
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val markerTint = if (charger.maxPower > 100) {
R.color.charger_100kw_dark // slightly darker color for better contrast
} else {
getMarkerTint(charger)
}
val color = ContextCompat.getColor(carContext, markerTint)
val place =
Place.Builder(CarLocation.create(charger.coordinates.lat, charger.coordinates.lng))
.setMarker(
PlaceMarker.Builder()
.setColor(CarColor.createCustom(color, color))
.build()
)
.build()
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)
.build())
} else {
setTitle(charger.name)
}
val text = SpannableStringBuilder()
// distance
location?.let {
val distanceMeters = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
)
text.append(
"distance",
DistanceSpan.create(
roundValueToDistance(
distanceMeters,
energyLevel?.distanceDisplayUnit?.value
)
),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
// power
if (text.isNotEmpty()) text.append(" · ")
text.append("${charger.maxPower.roundToInt()} kW")
// availability
availabilities[charger.id]?.second?.let { av ->
val status = av.status.values.flatten()
val available = availabilityText(status)
val total = charger.chargepoints.sumBy { it.count }
if (text.isNotEmpty()) text.append(" · ")
text.append(
"$available/$total",
ForegroundCarColorSpan.create(carAvailabilityColor(status)),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
addText(text)
setMetadata(
Metadata.Builder()
.setPlace(place)
.build()
)
setOnClickListener {
screenManager.push(ChargerDetailScreen(carContext, charger))
}
}.build()
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
val now = Instant.now()
if (lastDistanceUpdateTime == null ||
Duration.between(lastDistanceUpdateTime, now) > distanceUpdateThreshold
) {
lastDistanceUpdateTime = now
// update displayed distances
invalidate()
}
if (lastChargerUpdateLocation == null ||
location.distanceTo(lastChargerUpdateLocation) > chargerUpdateThreshold
) {
lastChargerUpdateLocation = location
// update displayed chargers
loadChargers()
}
}
private fun loadChargers() {
val location = location ?: return
val referenceData = referenceData.value ?: return
val filters = filtersWithValue.value ?: return
numUpdates++
println(numUpdates)
if (numUpdates > maxNumUpdates) {
/*CarToast.makeText(carContext, R.string.auto_no_refresh_possible, CarToast.LENGTH_LONG)
.show()*/
return
}
updateCoroutine = lifecycleScope.launch {
try {
// load chargers
if (favorites) {
chargers = db.chargeLocationsDao().getAllChargeLocationsAsync().sortedBy {
distanceBetween(
location.latitude, location.longitude,
it.coordinates.lat, it.coordinates.lng
)
}
} else {
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius,
zoom = 16f,
filters
)
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < maxRows) {
// try again with larger radius
val response = api.getChargepointsRadius(
referenceData,
LatLng.fromLocation(location),
searchRadius * 10,
zoom = 16f,
filters
)
chargers =
response.data?.filterIsInstance(ChargeLocation::class.java)
}
}
}
// remove outdated availabilities
availabilities = availabilities.filter {
Duration.between(
it.value.first,
ZonedDateTime.now()
) > availabilityUpdateThreshold
}.toMutableMap()
// update availabilities
chargers?.take(maxRows)?.map {
lifecycleScope.async {
// update only if not yet stored
if (!availabilities.containsKey(it.id)) {
val date = ZonedDateTime.now()
val availability = getAvailability(it).data
if (availability != null) {
availabilities[it.id] = date to availability
}
}
}
}?.awaitAll()
updateCoroutine = null
lastDistanceUpdateTime = Instant.now()
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
this.energyLevel = energyLevel
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (ContextCompat.checkSelfPermission(
carContext,
"com.google.android.gms.permission.CAR_FUEL"
) != PackageManager.PERMISSION_GRANTED
)
return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
}
}

View File

@@ -0,0 +1,50 @@
package net.vonforst.evmap.auto
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant permission
*/
class PermissionScreen(
ctx: CarContext,
@StringRes val message: Int,
val permissions: List<String>
) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(message))
.setTitle(carContext.getString(R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.grant_on_phone))
.setBackgroundColor(CarColor.PRIMARY)
.setOnClickListener(ParkedOnlyOnClickListener.create {
requestPermissions()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
private fun requestPermissions() {
carContext.requestPermissions(permissions) { granted, rejected ->
if (granted.containsAll(permissions)) {
screenManager.pop()
} else {
requestPermissions()
}
}
}
}

View File

@@ -0,0 +1,149 @@
package net.vonforst.evmap.auto
import android.content.Context
import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.api.availability.ChargepointStatus
import java.util.*
import kotlin.math.roundToInt
fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
val available = status.count { it == ChargepointStatus.AVAILABLE }
val allFaulted = status.all { it == ChargepointStatus.FAULTED }
return if (unknown) {
CarColor.DEFAULT
} else if (available > 0) {
CarColor.GREEN
} else if (allFaulted) {
CarColor.RED
} else {
CarColor.BLUE
}
}
val CarContext.constraintManager
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
private const val kmPerMile = 1.609344
private const val ftPerMile = 5280
private const val ydPerMile = 1760
fun getDefaultDistanceUnit(): Int {
return if (usesImperialUnits(Locale.getDefault())) {
CarUnit.MILE
} else {
CarUnit.KILOMETER
}
}
fun usesImperialUnits(locale: Locale): Boolean {
return locale.country in listOf("US", "GB", "MM", "LR")
|| locale.country == "" && locale.language == "en"
}
fun getDefaultSpeedUnit(): Int {
return when (Locale.getDefault().country) {
"US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
else -> CarUnit.KILOMETERS_PER_HOUR
}
}
fun formatCarUnitDistance(value: Float?, unit: Int?): String {
if (value == null) return ""
return when (unit ?: getDefaultDistanceUnit()) {
// distance units: base unit is meters
CarUnit.METER -> "%.0f m".format(value)
CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that...
CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile)
else -> ""
}
}
fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
if (value == null) return ""
return when (unit ?: getDefaultSpeedUnit()) {
// speed units: base unit is meters per second
CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile)
else -> ""
}
}
fun roundValueToDistance(value: Double, unit: Int? = null): Distance {
// value is in meters
when (unit ?: getDefaultDistanceUnit()) {
CarUnit.MILE -> {
// imperial system
val miles = value / 1000 / kmPerMile
val yards = miles * ydPerMile
val feet = miles * ftPerMile
return when (miles) {
in 0.0..0.1 -> if (Locale.getDefault().country == "UK") {
Distance.create(roundToMultipleOf(yards, 10.0), Distance.UNIT_YARDS)
} else {
Distance.create(roundToMultipleOf(feet, 10.0), Distance.UNIT_FEET)
}
in 0.1..10.0 -> Distance.create(
roundToMultipleOf(miles, 0.1),
Distance.UNIT_MILES_P1
)
else -> Distance.create(roundToMultipleOf(miles, 1.0), Distance.UNIT_MILES)
}
}
else -> {
// metric system
return when (value) {
in 0.0..999.0 -> Distance.create(
roundToMultipleOf(value, 10.0),
Distance.UNIT_METERS
)
in 1000.0..10000.0 -> Distance.create(
roundToMultipleOf(value / 1000, 0.1),
Distance.UNIT_KILOMETERS_P1
)
else -> Distance.create(
roundToMultipleOf(value / 1000, 1.0),
Distance.UNIT_KILOMETERS
)
}
}
}
}
private fun roundToMultipleOf(num: Double, step: Double): Double {
return (num / step).roundToInt() * step
}
fun getAndroidAutoVersion(ctx: Context): List<String> {
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
return info.versionName.split(".")
}
fun supportsCarApiLevel3(ctx: CarContext): Boolean {
if (ctx.carAppApiLevel < CarAppApiLevels.LEVEL_3) return false
ctx.hostInfo?.let { hostInfo ->
if (hostInfo.packageName == "com.google.android.projection.gearhead") {
val version = getAndroidAutoVersion(ctx)
// Android Auto 6.7 is required. 6.6 reports supporting API Level 3,
// but crashes when using it. See: https://issuetracker.google.com/issues/199509584
if (version[0] < "6" || version[0] == "6" && version[1] < "7") {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,215 @@
package net.vonforst.evmap.auto
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.Gauge
import kotlin.math.min
import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), LifecycleObserver {
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private var model: Model? = null
private var energyLevel: EnergyLevel? = null
private var speed: Speed? = null
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
private val permissions = listOf(
"com.google.android.gms.permission.CAR_FUEL",
"com.google.android.gms.permission.CAR_SPEED"
)
init {
lifecycle.addObserver(this)
}
override fun onGetTemplate(): Template {
if (!permissionsGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_vehicle_data_permission_needed,
permissions
)
) {
setupListeners()
}
}
}
val energyLevel = energyLevel
val model = model
val speed = speed
return GridTemplate.Builder().apply {
setTitle(
if (model != null && model.manufacturer.value != null && model.name.value != null) {
"${model.manufacturer.value} ${model.name.value}"
} else {
carContext.getString(R.string.auto_vehicle_data)
}
)
setHeaderAction(Action.BACK)
if (!permissionsGranted()) {
setLoading(true)
} else {
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_charging_level))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) {
// both battery and fuel (Plug-in hybrid)
setText(
"\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
)
)
setImage(
gauge.draw(
energyLevel.batteryPercent.value,
energyLevel.fuelPercent.value
).asCarIcon()
)
} else if (energyLevel.batteryPercent.value != null) {
// BEV
setText("%.0f %%".format(energyLevel.batteryPercent.value))
setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon())
} else if (energyLevel.fuelPercent.value != null) {
// ICE
setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value))
setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon())
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_range))
if (energyLevel == null) {
setLoading(true)
} else if (energyLevel.rangeRemainingMeters.value != null) {
setText(
formatCarUnitDistance(
energyLevel.rangeRemainingMeters.value,
energyLevel.distanceDisplayUnit.value
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_car
)
).build()
)
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.auto_speed))
if (speed == null) {
setLoading(true)
} else {
val rawSpeed = speed.rawSpeedMetersPerSecond.value
val displaySpeed = speed.displaySpeedMetersPerSecond.value
if (rawSpeed != null) {
setText(
formatCarUnitSpeed(
rawSpeed,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon()
)
} else if (displaySpeed != null) {
setText(
formatCarUnitSpeed(
speed.displaySpeedMetersPerSecond.value,
speed.speedDisplayUnit.value
)
)
setImage(
gauge.draw(min(displaySpeed / maxSpeed * 100, 100f))
.asCarIcon()
)
} else {
setText(carContext.getString(R.string.auto_no_data))
setImage(gauge.draw(0f).asCarIcon())
}
}
}.build())
}.build()
)
}
}.build()
}
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
this.energyLevel = energyLevel
invalidate()
}
private fun onSpeedUpdated(speed: Speed) {
this.speed = speed
invalidate()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
private fun setupListeners() {
if (!permissionsGranted()) return
println("Setting up energy level listener")
val exec = ContextCompat.getMainExecutor(carContext)
hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
hardwareMan.carInfo.fetchModel(exec) {
this.model = it
invalidate()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
private fun removeListeners() {
println("Removing energy level listener")
hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
}
private fun permissionsGranted(): Boolean =
permissions.all {
ContextCompat.checkSelfPermission(
carContext,
it
) == PackageManager.PERMISSION_GRANTED
}
}

View File

@@ -0,0 +1,121 @@
package net.vonforst.evmap.auto
import android.Manifest
import android.location.Location
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.*
import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.R
/**
* Welcome screen with selection between favorites and nearby chargers
*/
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
if (!session.locationPermissionGranted()) {
Handler(Looper.getMainLooper()).post {
screenManager.pushForResult(
PermissionScreen(
carContext,
R.string.auto_location_permission_needed,
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
) {
session.bindLocationService()
}
}
}
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
if (!session.locationPermissionGranted()) {
setLoading(true)
} else {
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(
MapScreen(
carContext,
session,
favorites = false
)
)
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_vehicle_data))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(carContext, R.drawable.ic_car)
).setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
session.mapScreen = null
screenManager.push(VehicleDataScreen(carContext))
}
.build()
)
}
}.build())
setCurrentLocationEnabled(true)
}
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
if (location.latitude == this.location?.latitude
&& location.longitude == this.location?.longitude
) {
return
}
this.location = location
invalidate()
}
}

View File

@@ -1,33 +1,11 @@
package net.vonforst.evmap.autocomplete
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE
import net.vonforst.evmap.viewmodel.PlaceWithBounds
import net.vonforst.evmap.storage.PreferenceDataSource
fun launchAutocomplete(fragment: Fragment) {
val fields = listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)
val intent: Intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, fields
)
.build(fragment.requireActivity())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE)
// show keyboard
val imm = fragment.requireContext()
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, 0)
}
fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? {
val place = Autocomplete.getPlaceFromIntent(intent)
return PlaceWithBounds(AnyMapAdapter.adapt(place.latLng), AnyMapAdapter.adapt(place.viewport))
}
fun getAutocompleteProviders(context: Context) =
if (PreferenceDataSource(context).searchProvider == "google") {
listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context))
} else {
listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context))
}

View File

@@ -0,0 +1,112 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.graphics.Typeface
import android.text.style.CharacterStyle
import android.text.style.StyleSpan
import com.car2go.maps.google.adapter.AnyMapAdapter
import com.car2go.maps.util.SphericalUtil
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.tasks.Tasks.await
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.model.AutocompleteSessionToken
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.api.model.RectangularBounds
import com.google.android.libraries.places.api.net.FetchPlaceRequest
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
import com.google.android.libraries.places.api.net.PlacesStatusCodes
import kotlinx.coroutines.tasks.await
import net.vonforst.evmap.R
import java.util.concurrent.ExecutionException
import kotlin.math.sqrt
class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvider {
private var token = AutocompleteSessionToken.newInstance()
private val client = Places.createClient(context)
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
override val id = "google"
override fun autocomplete(
query: String,
location: com.car2go.maps.model.LatLng?
): List<AutocompletePlace> {
val request = FindAutocompletePredictionsRequest.builder().apply {
if (location != null) {
setLocationBias(calcLocationBias(location))
setOrigin(LatLng(location.latitude, location.longitude))
}
setSessionToken(token)
setQuery(query)
}.build()
try {
val result =
await(client.findAutocompletePredictions(request)).autocompletePredictions
return result.map {
AutocompletePlace(
it.getPrimaryText(bold),
it.getSecondaryText(bold),
it.placeId,
it.distanceMeters?.toDouble(),
it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) })
}
} catch (e: ExecutionException) {
val cause = e.cause
if (cause is ApiException) {
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
}
}
throw e
}
}
override suspend fun getDetails(id: String): PlaceWithBounds {
val request =
FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build()
try {
val place = client.fetchPlace(request).await().place
token = AutocompleteSessionToken.newInstance()
return PlaceWithBounds(
AnyMapAdapter.adapt(place.latLng),
AnyMapAdapter.adapt(place.viewport)
)
} catch (e: ApiException) {
if (e.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
throw ApiUnavailableException()
} else {
throw e
}
}
}
override fun getAttributionString(): Int = R.string.places_powered_by_google
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds {
val radius = 100e3 // meters
val northEast =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
45.0
)
val southWest =
SphericalUtil.computeOffset(
location,
radius * sqrt(2.0),
225.0
)
return RectangularBounds.newInstance(
LatLngBounds(
AnyMapAdapter.adapt(southWest),
AnyMapAdapter.adapt(northEast)
)
)
}
}

View File

@@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -28,7 +27,7 @@ class DonateFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_donate, container, false)
binding.lifecycleOwner = this
binding.vm = vm
@@ -36,14 +35,7 @@ class DonateFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
binding.productsList.apply {
adapter = DonationAdapter().apply {
@@ -54,6 +46,10 @@ class DonateFragment : Fragment() {
layoutManager = LinearLayoutManager(context)
}
vm.products.observe(viewLifecycleOwner) {
print(it)
}
vm.purchaseSuccessful.observe(viewLifecycleOwner, Observer {
Snackbar.make(view, R.string.donation_successful, Snackbar.LENGTH_LONG).show()
})
@@ -61,4 +57,12 @@ class DonateFragment : Fragment() {
Snackbar.make(view, R.string.donation_failed, Snackbar.LENGTH_LONG).show()
})
}
override fun onResume() {
super.onResume()
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
}
}

View File

@@ -0,0 +1,69 @@
package net.vonforst.evmap.fragment
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import net.vonforst.evmap.databinding.FragmentOnboardingAndroidAutoBinding
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> AndroidAutoFragment()
3 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
class AndroidAutoFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingAndroidAutoBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingAndroidAutoBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
binding.imgAndroidAuto.alpha = 0f
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators =
listOf(
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "translationY", -20f, 0f).apply {
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(binding.imgAndroidAuto, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}
)
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
binding.imgAndroidAuto.alpha = 0f
}
}

View File

@@ -0,0 +1,74 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.graphics.*
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import net.vonforst.evmap.R
import kotlin.math.max
import kotlin.math.min
class Gauge(val size: Int, ctx: Context) {
val arcPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = size * 0.15f
}
val gaugePaint = Paint()
val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active)
val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle)
val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive)
fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
val angle = valuePercent?.let { 180f * it / 100 } ?: 0f
val secondAngle = secondValuePercent?.let { 180f * it / 100 }
drawArc(angle, secondAngle, canvas)
if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas)
drawGauge(angle, Color.WHITE, canvas)
return bitmap
}
private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) {
gaugePaint.color = color
canvas.save()
canvas.rotate(angle - 90, size / 2f, 3 * size / 4f)
canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint)
canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint)
canvas.restore()
}
private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) {
val (angle1, angle2) = if (secondAngle != null) {
min(angle, secondAngle) to max(angle, secondAngle)
} else {
angle to null
}
arcPaint.color = activeColor
val arcBounds = RectF(
arcPaint.strokeWidth / 2,
size / 4f + arcPaint.strokeWidth / 2,
size - arcPaint.strokeWidth / 2,
5 * size / 4f - arcPaint.strokeWidth / 2
)
canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint)
if (angle2 != null) {
arcPaint.color = middleColor
canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint)
}
arcPaint.color = inactiveColor
canvas.drawArc(
arcBounds,
180f + (angle2 ?: angle1),
180f - (angle2 ?: angle1),
false,
arcPaint
)
}
}

View File

@@ -54,12 +54,12 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
.build()
billingClient.querySkuDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
products.value = Resource.success(details
products.postValue(Resource.success(details
.sortedBy { it.priceAmountMicros }
.map { DonationItem(it) }
)
))
} else {
products.value = Resource.error(result.debugMessage, null)
products.postValue(Resource.error(result.debugMessage, null))
}
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z" />
</vector>

View File

@@ -51,7 +51,7 @@
android:id="@+id/products_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginTop="16dp"
app:data="@{vm.products.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:gravity="center"
android:text="@string/welcome_android_auto"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="56dp"
android:breakStrategy="balanced"
android:gravity="center"
android:text="@string/welcome_android_auto_detail"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/sounds_cool"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/img_android_auto"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginTop="28dp"
android:layout_marginBottom="28dp"
android:background="@drawable/circle_bg_logo"
android:backgroundTint="@color/android_auto_accent"
android:scaleType="center"
app:layout_constraintBottom_toTopOf="@+id/welcomeTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.7"
app:srcCompat="@drawable/android_auto" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -25,8 +25,9 @@
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@{item.sku.title}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
@@ -34,7 +35,7 @@
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Spende" />
tools:text="Spende (extrem langer Beschreibungstext)" />
<TextView
android:id="@+id/textView21"

View File

@@ -4,5 +4,33 @@
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab.</string>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string name="donations_info" formatted="false">Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab.</string>
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
<string name="auto_no_favorites_found">Keine Favoriten gefunden</string>
<string name="open_in_app">In App öffnen</string>
<string name="opened_on_phone">Auf dem Telefon geöffnet</string>
<string name="auto_location_permission_needed">Um EVMap auf Android Auto zu nutzen, braucht die App Zugriff auf deinen Standort.</string>
<string name="auto_vehicle_data_permission_needed">Für diese Funktion benötigt EVMap Zugriff auf Daten deines Fahrzeugs.</string>
<string name="grant_on_phone">Auf Telefon zulassen</string>
<string name="auto_chargers_closeby">In der Nähe</string>
<string name="auto_favorites">Favoriten</string>
<string name="auto_fault_report_date">⚠️ Störungsmeldung (%s)</string>
<string name="auto_no_refresh_possible">Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.</string>
<string name="auto_prices">Preise</string>
<string name="auto_vehicle_data">Fahrzeugdaten</string>
<string name="auto_charging_level">Ladezustand</string>
<string name="auto_no_data">Nicht verfügbar</string>
<string name="auto_range">Reichweite</string>
<string name="auto_speed">Geschwindigkeit</string>
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
<string name="sounds_cool">klingt cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="gauge_active">#00e676</color>
<color name="gauge_middle">#087f23</color>
<color name="gauge_inactive">#9e9e9e</color>
<color name="charger_100kw_dark">#fdd835</color>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CarAppTheme">
<item name="carColorPrimary">@color/colorPrimary</item>
<item name="carColorPrimaryDark">@color/colorPrimaryVariant</item>
<item name="carColorSecondary">@color/colorSecondary</item>
<item name="carColorSecondaryDark">@color/colorSecondaryVariant</item>
</style>
</resources>

View File

@@ -8,6 +8,39 @@
<item>google</item>
<item>mapbox</item>
</string-array>
<string-array name="pref_search_provider_names">
<item>Google Maps</item>
<item>OpenStreetMap (Mapbox)</item>
</string-array>
<string-array name="pref_search_provider_values" tranlatable="false">
<item>google</item>
<item>mapbox</item>
</string-array>
<string name="pref_map_provider_default" translatable="false">google</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 30% off every donation.</string>
<string name="pref_search_provider_default" translatable="false">mapbox</string>
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
<string name="auto_no_chargers_found">No nearby chargers found</string>
<string name="auto_no_favorites_found">No favorites found</string>
<string name="open_in_app">Open in app</string>
<string name="opened_on_phone">Opened on phone</string>
<string name="auto_location_permission_needed">To run EVMap on Android Auto, you need to grant access to your location.</string>
<string name="auto_vehicle_data_permission_needed">For this feature, EVMap needs access to your vehicle data.</string>
<string name="grant_on_phone">Grant on phone</string>
<string name="auto_chargers_closeby">Nearby chargers</string>
<string name="auto_favorites">Favorites</string>
<string name="auto_fault_report_date">⚠️ Fault report (%s)</string>
<string name="auto_no_refresh_possible">Further updates not possible. Please go back and restart.</string>
<string name="auto_prices">Pricing</string>
<string name="auto_vehicle_data">Vehicle data</string>
<string name="auto_charging_level">Charging level</string>
<string name="auto_no_data">Unavailable</string>
<string name="auto_range">Range</string>
<string name="auto_speed">Speed</string>
<string name="welcome_android_auto">Android Auto support</string>
<string name="welcome_android_auto_detail">You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.</string>
<string name="sounds_cool">sounds cool</string>
<string name="auto_chargeprice_vehicle_unavailable">EVMap could not determine your vehicle model.</string>
<string name="auto_chargeprice_vehicle_unknown">None of the vehicles selected in the app matches this vehicle (%s %s).</string>
<string name="auto_chargeprice_vehicle_ambiguous">Mehrere der in der App ausgewählten Fahrzeuge passen zu diesem Fahrzeug (%s %s).</string>
</resources>

View File

@@ -0,0 +1,5 @@
<automotiveApp xmlns:tools="http://schemas.android.com/tools">
<uses
name="template"
tools:ignore="InvalidUsesTagAttribute" />
</automotiveApp>

View File

@@ -3,6 +3,7 @@
package="net.vonforst.evmap">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<queries>
@@ -31,14 +32,242 @@
<activity
android:name=".MapsActivity"
android:label="@string/title_activity_maps"
android:theme="@style/AppTheme.LaunchScreen">
android:label="@string/app_name"
android:theme="@style/AppTheme.LaunchScreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Deutschland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Oesterreich/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Schweiz/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Albanien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Andorra/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Aruba/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Belarus/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Belgien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Bosnien-und-Herzegowina/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Bulgarien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Daenemark/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Estland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Faeroeer/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Finnland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Frankreich/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Gibraltar/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Griechenland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Grossbritannien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Irland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Island/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Italien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Jordanien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Kasachstan/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Kroatien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Lettland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Liechtenstein/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Litauen/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Luxemburg/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Malta/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Marokko/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Mazedonien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Moldawien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Monaco/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Montenegro/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Niederlande/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Norwegen/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Polen/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Portugal/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Rumaenien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Russland/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/San-Marino/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Schweden/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Serbien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Slowakei/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Slowenien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Spanien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Tuerkei/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Tschechien/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/USA/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Ukraine/..*/..*/..*/"
android:scheme="https" />
<data
android:host="www.goingelectric.de"
android:pathPattern="/stromtankstellen/Ungarn/..*/..*/..*/"
android:scheme="https" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
<!-- Override services of the com.mapzen.android.lost library with exported:false
until https://github.com/lostzen/lost/pull/270 is merged -->
<service
android:name="com.mapzen.android.lost.internal.GeofencingIntentService"
android:exported="false">
<intent-filter>
<action android:name="com.mapzen.lost.action.ACTION_GEOFENCING_SERVICE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,321 @@
/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
/**
* Copy of android.widget.Filter, exposing the hidden setDelayer() method.
*
* <p>A filter constrains data with a filtering pattern.</p>
*
* <p>Filters are usually created by {@link android.widget.Filterable}
* classes.</p>
*
* <p>Filtering operations performed by calling {@link #filter(CharSequence)} or
* {@link #filter(CharSequence, android.widget.Filter.FilterListener)} are
* performed asynchronously. When these methods are called, a filtering request
* is posted in a request queue and processed later. Any call to one of these
* methods will cancel any previous non-executed filtering request.</p>
*
* @see android.widget.Filterable
*/
public abstract class Filter {
private static final String LOG_TAG = "Filter";
private static final String THREAD_NAME = "Filter";
private static final int FILTER_TOKEN = 0xD0D0F00D;
private static final int FINISH_TOKEN = 0xDEADBEEF;
private Handler mThreadHandler;
private Handler mResultHandler;
private Delayer mDelayer;
private final Object mLock = new Object();
/**
* <p>Creates a new asynchronous filter.</p>
*/
public Filter() {
mResultHandler = new ResultsHandler();
}
/**
* Provide an interface that decides how long to delay the message for a given query. Useful
* for heuristics such as posting a delay for the delete key to avoid doing any work while the
* user holds down the delete key.
*
* @param delayer The delayer.
* @hide
*/
public void setDelayer(Delayer delayer) {
synchronized (mLock) {
mDelayer = delayer;
}
}
/**
* <p>Starts an asynchronous filtering operation. Calling this method
* cancels all previous non-executed filtering requests and posts a new
* filtering request that will be executed later.</p>
*
* @param constraint the constraint used to filter the data
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
*/
public final void filter(CharSequence constraint) {
filter(constraint, null);
}
/**
* <p>Starts an asynchronous filtering operation. Calling this method
* cancels all previous non-executed filtering requests and posts a new
* filtering request that will be executed later.</p>
*
* <p>Upon completion, the listener is notified.</p>
*
* @param constraint the constraint used to filter the data
* @param listener a listener notified upon completion of the operation
* @see #filter(CharSequence)
* @see #performFiltering(CharSequence)
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
*/
public final void filter(CharSequence constraint, FilterListener listener) {
synchronized (mLock) {
if (mThreadHandler == null) {
HandlerThread thread = new HandlerThread(
THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
mThreadHandler = new RequestHandler(thread.getLooper());
}
final long delay = (mDelayer == null) ? 0 : mDelayer.getPostingDelay(constraint);
Message message = mThreadHandler.obtainMessage(FILTER_TOKEN);
RequestArguments args = new RequestArguments();
// make sure we use an immutable copy of the constraint, so that
// it doesn't change while the filter operation is in progress
args.constraint = constraint != null ? constraint.toString() : null;
args.listener = listener;
message.obj = args;
mThreadHandler.removeMessages(FILTER_TOKEN);
mThreadHandler.removeMessages(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(message, delay);
}
}
/**
* <p>Invoked in a worker thread to filter the data according to the
* constraint. Subclasses must implement this method to perform the
* filtering operation. Results computed by the filtering operation
* must be returned as a {@link android.widget.Filter.FilterResults} that
* will then be published in the UI thread through
* {@link #publishResults(CharSequence,
* android.widget.Filter.FilterResults)}.</p>
*
* <p><strong>Contract:</strong> When the constraint is null, the original
* data must be restored.</p>
*
* @param constraint the constraint used to filter the data
* @return the results of the filtering operation
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
* @see #publishResults(CharSequence, android.widget.Filter.FilterResults)
* @see android.widget.Filter.FilterResults
*/
protected abstract FilterResults performFiltering(CharSequence constraint);
/**
* <p>Invoked in the UI thread to publish the filtering results in the
* user interface. Subclasses must implement this method to display the
* results computed in {@link #performFiltering}.</p>
*
* @param constraint the constraint used to filter the data
* @param results the results of the filtering operation
* @see #filter(CharSequence, android.widget.Filter.FilterListener)
* @see #performFiltering(CharSequence)
* @see android.widget.Filter.FilterResults
*/
protected abstract void publishResults(CharSequence constraint,
FilterResults results);
/**
* <p>Converts a value from the filtered set into a CharSequence. Subclasses
* should override this method to convert their results. The default
* implementation returns an empty String for null values or the default
* String representation of the value.</p>
*
* @param resultValue the value to convert to a CharSequence
* @return a CharSequence representing the value
*/
public CharSequence convertResultToString(Object resultValue) {
return resultValue == null ? "" : resultValue.toString();
}
/**
* <p>Holds the results of a filtering operation. The results are the values
* computed by the filtering operation and the number of these values.</p>
*/
protected static class FilterResults {
public FilterResults() {
// nothing to see here
}
/**
* <p>Contains all the values computed by the filtering operation.</p>
*/
public Object values;
/**
* <p>Contains the number of values computed by the filtering
* operation.</p>
*/
public int count;
}
/**
* <p>Listener used to receive a notification upon completion of a filtering
* operation.</p>
*/
public static interface FilterListener {
/**
* <p>Notifies the end of a filtering operation.</p>
*
* @param count the number of values computed by the filter
*/
public void onFilterComplete(int count);
}
/**
* <p>Worker thread handler. When a new filtering request is posted from
* {@link android.widget.Filter#filter(CharSequence, android.widget.Filter.FilterListener)},
* it is sent to this handler.</p>
*/
private class RequestHandler extends Handler {
public RequestHandler(Looper looper) {
super(looper);
}
/**
* <p>Handles filtering requests by calling
* {@link Filter#performFiltering} and then sending a message
* with the results to the results handler.</p>
*
* @param msg the filtering request
*/
public void handleMessage(Message msg) {
int what = msg.what;
Message message;
switch (what) {
case FILTER_TOKEN:
RequestArguments args = (RequestArguments) msg.obj;
try {
args.results = performFiltering(args.constraint);
} catch (Exception e) {
args.results = new FilterResults();
Log.w(LOG_TAG, "An exception occured during performFiltering()!", e);
} finally {
message = mResultHandler.obtainMessage(what);
message.obj = args;
message.sendToTarget();
}
synchronized (mLock) {
if (mThreadHandler != null) {
Message finishMessage = mThreadHandler.obtainMessage(FINISH_TOKEN);
mThreadHandler.sendMessageDelayed(finishMessage, 3000);
}
}
break;
case FINISH_TOKEN:
synchronized (mLock) {
if (mThreadHandler != null) {
mThreadHandler.getLooper().quit();
mThreadHandler = null;
}
}
break;
}
}
}
/**
* <p>Handles the results of a filtering operation. The results are
* handled in the UI thread.</p>
*/
private class ResultsHandler extends Handler {
/**
* <p>Messages received from the request handler are processed in the
* UI thread. The processing involves calling
* {@link Filter#publishResults(CharSequence,
* android.widget.Filter.FilterResults)}
* to post the results back in the UI and then notifying the listener,
* if any.</p>
*
* @param msg the filtering results
*/
@Override
public void handleMessage(Message msg) {
RequestArguments args = (RequestArguments) msg.obj;
publishResults(args.constraint, args.results);
if (args.listener != null) {
int count = args.results != null ? args.results.count : -1;
args.listener.onFilterComplete(count);
}
}
}
/**
* <p>Holds the arguments of a filtering request as well as the results
* of the request.</p>
*/
private static class RequestArguments {
/**
* <p>The constraint used to filter the data.</p>
*/
CharSequence constraint;
/**
* <p>The listener to notify upon completion. Can be null.</p>
*/
FilterListener listener;
/**
* <p>The results of the filtering operation.</p>
*/
FilterResults results;
}
/**
* @hide
*/
public interface Delayer {
/**
* @param constraint The constraint passed to {@link Filter#filter(CharSequence)}
* @return The delay that should be used for
* {@link Handler#sendMessageDelayed(android.os.Message, long)}
*/
long getPostingDelay(CharSequence constraint);
}
}

View File

@@ -4,6 +4,11 @@ import android.app.Application
import com.facebook.stetho.Stetho
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.updateNightMode
import org.acra.config.dialog
import org.acra.config.limiter
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
class EvMapApplication : Application() {
override fun onCreate() {
@@ -11,5 +16,28 @@ class EvMapApplication : Application() {
updateNightMode(PreferenceDataSource(this))
Stetho.initializeWithDefaults(this);
init(applicationContext)
if (!BuildConfig.DEBUG) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
mailSender {
mailTo = "evmap+crashreport@vonforst.net"
}
dialog {
text = getString(R.string.crash_report_text)
title = getString(R.string.app_name)
commentPrompt = getString(R.string.crash_report_comment_prompt)
resIcon = R.drawable.ic_launcher_foreground
resTheme = R.style.AppTheme
}
limiter {
enabled = true
}
}
}
}
}

View File

@@ -1,29 +1,46 @@
package net.vonforst.evmap
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigator
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.car2go.maps.model.LatLng
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.fragment.MapFragmentArgs
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
import net.vonforst.evmap.utils.getLocationFromIntent
const val REQUEST_LOCATION_PERMISSION = 1
const val EXTRA_CHARGER_ID = "chargerId"
const val EXTRA_LAT = "lat"
const val EXTRA_LON = "lon"
class MapsActivity : AppCompatActivity() {
class MapsActivity : AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
interface FragmentCallback {
fun getRootView(): View
}
@@ -43,13 +60,13 @@ class MapsActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
// set theme to AppTheme to end launch screen
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
setContentView(R.layout.activity_maps)
navController = findNavController(R.id.nav_host_fragment)
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
appBarConfiguration = AppBarConfiguration(
setOf(
R.id.map,
@@ -59,11 +76,92 @@ class MapsActivity : AppCompatActivity() {
),
findViewById<DrawerLayout>(R.id.drawer_layout)
)
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
val header = navView.getHeaderView(0)
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
}
prefs = PreferenceDataSource(this)
prefs.appStartCounter += 1
checkPlayServices(this)
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// wait for splash screen animation to finish on first start
splashScreen.setKeepVisibleCondition(object : SplashScreen.KeepOnScreenCondition {
var startTime: Long? = null
override fun shouldKeepOnScreen(): Boolean {
val st = startTime
if (st == null) {
startTime = SystemClock.uptimeMillis()
return true
} else {
return (SystemClock.uptimeMillis() - st) < 1000
}
}
})
}
navGraph.setStartDestination(R.id.onboarding)
navController.graph = navGraph
return
} else {
navGraph.setStartDestination(R.id.map)
navController.graph = navGraph
}
if (intent?.scheme == "geo") {
val query = intent.data?.query?.split("=")?.get(1)
val coords = getLocationFromIntent(intent)
if (coords != null) {
val lat = coords[0]
val lon = coords[1]
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(latLng = LatLng(lat, lon)).toBundle())
.createPendingIntent()
deepLink.send()
} else if (query != null && query.isNotEmpty()) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(locationName = query).toBundle())
.createPendingIntent()
deepLink.send()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
if (id != null) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
.createPendingIntent()
deepLink.send()
}
} else if (intent.hasExtra(EXTRA_CHARGER_ID)) {
navController.createDeepLink()
.setDestination(R.id.map)
.setArguments(
MapFragmentArgs(
chargerId = intent.getLongExtra(EXTRA_CHARGER_ID, 0),
latLng = LatLng(
intent.getDoubleExtra(EXTRA_LAT, 0.0),
intent.getDoubleExtra(EXTRA_LON, 0.0)
)
).toBundle()
)
.createPendingIntent()
.send()
}
}
fun navigateTo(charger: ChargeLocation) {
@@ -103,7 +201,16 @@ class MapsActivity : AppCompatActivity() {
.build()
)
.build()
intent.launchUrl(this, Uri.parse(url))
try {
intent.launchUrl(this, Uri.parse(url))
} catch (e: ActivityNotFoundException) {
val cb = fragmentCallback ?: return
Snackbar.make(
cb.getRootView(),
R.string.no_browser_app_found,
Snackbar.LENGTH_SHORT
).show()
}
}
fun shareUrl(url: String) {
@@ -113,4 +220,15 @@ class MapsActivity : AppCompatActivity() {
}
startActivity(intent)
}
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference
): Boolean {
// Identify the Navigation Destination
val navDestination = navController.graph
.find { target -> target is FragmentNavigator.Destination && pref.fragment == target.className }
navDestination?.let { target -> navController.navigate(target.id) }
return true
}
}

View File

@@ -1,9 +1,17 @@
package net.vonforst.evmap
import android.content.Context
import android.content.res.Configuration
import android.graphics.Typeface
import android.os.Bundle
import android.text.*
import android.text.style.StyleSpan
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.util.*
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
@@ -51,4 +59,57 @@ fun String.bold(): CharSequence {
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}
fun <T> Collection<Iterable<T>>.cartesianProduct(): Set<Set<T>> =
/**
Returns all possible combinations of entries of a list
*/
if (isEmpty()) emptySet()
else drop(1).fold(first().map(::setOf)) { acc, iterable ->
acc.flatMap { list -> iterable.map(list::plus) }
}.toSet()
fun max(a: Int?, b: Int?): Int? {
/**
* Returns the maximum of two values of both are non-null,
* otherwise the non-null value or null
*/
return if (a != null && b != null) {
max(a, b)
} else {
a ?: b
}
}
fun <T> List<T>.containsAny(vararg values: T) = values.any { this.contains(it) }
public suspend fun <T> LiveData<T>.await(): T {
return withContext(Dispatchers.Main.immediate) {
suspendCancellableCoroutine { continuation ->
val observer = object : Observer<T> {
override fun onChanged(value: T) {
removeObserver(this)
continuation.resume(value, null)
}
}
observeForever(observer)
continuation.invokeOnCancellation {
removeObserver(observer)
}
}
}
}
fun Context.isDarkMode() =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
const val kmPerMile = 1.609344
const val meterPerFt = 0.3048
fun shouldUseImperialUnits(): Boolean {
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
}

View File

@@ -1,20 +1,30 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceTag
import net.vonforst.evmap.databinding.ItemChargepriceBinding
import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
interface Equatable {
override fun equals(other: Any?): Boolean;
override fun equals(other: Any?): Boolean
}
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
@@ -78,14 +88,139 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
class FavoritesAdapter(val vm: FavoritesViewModel) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
init {
setHasStableIds(true)
val viewPool = RecyclerView.RecycledViewPool();
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffs: Set<String>? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffsAll: Boolean? = null
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<ChargePrice> {
val holder = super.onCreateViewHolder(parent, viewType)
val binding = holder.binding as ItemChargepriceBinding
binding.rvTags.apply {
adapter = ChargepriceTagsAdapter()
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false).apply {
recycleChildrenOnDetach = true
}
itemAnimator = null
setRecycledViewPool(viewPool)
}
return holder
}
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun bind(holder: ViewHolder<ChargePrice>, item: ChargePrice) {
super.bind(holder, item)
(holder.binding as ItemChargepriceBinding).apply {
this.meta = this@ChargepriceAdapter.meta
this.myTariffs = this@ChargepriceAdapter.myTariffs
this.myTariffsAll = this@ChargepriceAdapter.myTariffsAll
}
}
}
override fun getItemId(position: Int): Long = getItem(position).charger.id
class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
private var checkedItem: Int? = 0
var enabledConnectors: List<String>? = null
get() = field
set(value) {
field = value
checkedItem?.let {
if (value != null && getItem(it).type !in value) {
val index = currentList.indexOfFirst {
it.type in value
}
checkedItem = if (index == -1) null else index
onCheckedItemChangedListener?.invoke(getCheckedItem())
}
}
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_connector_button
override fun onBindViewHolder(holder: ViewHolder<Chargepoint>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemConnectorButtonBinding
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
val root = binding.root as CheckableConstraintLayout
root.isChecked = checkedItem == position
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked) {
checkedItem = holder.bindingAdapterPosition
root.post {
notifyDataSetChanged()
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
fun setCheckedItem(item: Chargepoint?) {
checkedItem = item?.let { currentList.indexOf(item) }
}
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
}
class ChargepriceTagsAdapter() :
DataBindingAdapter<ChargepriceTag>() {
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_tag
}
class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
private var checkedItem: ChargepriceCar? = null
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_vehicle_chip
override fun onBindViewHolder(holder: ViewHolder<ChargepriceCar>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemChargepriceVehicleChipBinding
val root = binding.root as Chip
root.isChecked = checkedItem == item
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked && item != checkedItem) {
checkedItem = item
root.post {
notifyDataSetChanged()
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}
fun getCheckedItem(): ChargepriceCar? = checkedItem
fun setCheckedItem(item: ChargepriceCar?) {
checkedItem = item
}
var onCheckedItemChangedListener: ((ChargepriceCar?) -> Unit)? = null
}

View File

@@ -3,12 +3,12 @@ package net.vonforst.evmap.adapter
import android.content.Context
import androidx.core.text.HtmlCompat
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeCardId
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.OpeningHoursDays
import net.vonforst.evmap.bold
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -18,7 +18,7 @@ class DetailsAdapter : DataBindingAdapter<DetailsAdapter.Detail>() {
data class Detail(
val icon: Int,
val contentDescription: Int,
val text: CharSequence,
val text: CharSequence?,
val detailText: CharSequence? = null,
val links: Boolean = true,
val clickable: Boolean = false,
@@ -84,26 +84,34 @@ fun buildDetails(
loc.openinghours.getStatusText(ctx)
else
loc.openinghours.description ?: "",
if (loc.openinghours.days != null) loc.openinghours.description else null,
if (loc.openinghours.days != null || loc.openinghours.twentyfourSeven) loc.openinghours.description else null,
hoursDays = loc.openinghours.days
) else null,
if (loc.cost != null) DetailsAdapter.Detail(
if (loc.cost != null && !loc.cost.isEmpty) DetailsAdapter.Detail(
R.drawable.ic_cost,
R.string.cost,
loc.cost.getStatusText(ctx),
loc.cost.descriptionLong ?: loc.cost.descriptionShort
)
else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) DetailsAdapter.Detail(
R.drawable.ic_payment,
R.string.charge_cards,
ctx.resources.getQuantityString(
R.plurals.charge_cards_compatible_num,
loc.chargecards.size, loc.chargecards.size
),
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx),
clickable = true
) else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty() || loc.barrierFree == true)
DetailsAdapter.Detail(
R.drawable.ic_payment,
R.string.charge_cards,
listOfNotNull(
if (loc.barrierFree == true) ctx.resources.getString(R.string.charging_barrierfree) else null,
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) {
ctx.resources.getQuantityString(
R.plurals.charge_cards_compatible_num,
loc.chargecards.size, loc.chargecards.size
)
} else null
).joinToString(", "),
if (loc.chargecards != null && loc.chargecards.isNotEmpty()) {
formatChargeCards(loc.chargecards, chargeCards, filteredChargeCards, ctx)
} else null,
clickable = true
) else null,
DetailsAdapter.Detail(
R.drawable.ic_location,
R.string.coordinates,
@@ -111,7 +119,7 @@ fun buildDetails(
loc.coordinates.formatDecimal(),
links = false,
clickable = true
)
),
)
}

View File

@@ -0,0 +1,39 @@
package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.view.animation.AccelerateInterpolator
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.viewmodel.FavoritesViewModel
class FavoritesAdapter(val onDelete: (FavoritesViewModel.FavoritesListItem) -> Unit) :
DataBindingAdapter<FavoritesViewModel.FavoritesListItem>() {
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int = R.layout.item_favorite
override fun getItemId(position: Int): Long = getItem(position).charger.id
@SuppressLint("ClickableViewAccessibility")
override fun bind(
holder: ViewHolder<FavoritesViewModel.FavoritesListItem>,
item: FavoritesViewModel.FavoritesListItem
) {
super.bind(holder, item)
val binding = holder.binding as ItemFavoriteBinding
binding.foreground.translationX = 0f
binding.btnDelete.setOnClickListener {
binding.foreground.animate()
.translationX(binding.foreground.width.toFloat())
.setDuration(250)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
onDelete(item)
}
.start()
}
}
}

View File

@@ -12,7 +12,7 @@ import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceBinding
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceLargeBinding
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.fragment.MultiSelectDialog
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.model.*
import kotlin.math.max
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {

View File

@@ -2,7 +2,9 @@ package net.vonforst.evmap.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.*
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -11,19 +13,11 @@ import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.model.ChargerPhoto
class GalleryAdapter(
context: Context,
val itemClickListener: ItemClickListener? = null,
val detailView: Boolean = false,
val pageToLoad: Int? = null,
val imageCacheKey: MemoryCache.Key? = null,
val loadedListener: (() -> Unit)? = null
) :
class GalleryAdapter(context: Context, val itemClickListener: ItemClickListener? = null) :
ListAdapter<ChargerPhoto, GalleryAdapter.ViewHolder>(ChargerPhotoDiffCallback()) {
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
@@ -38,106 +32,34 @@ class GalleryAdapter(
@SuppressLint("ClickableViewAccessibility")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view: ImageView
if (detailView) {
view = inflater.inflate(R.layout.gallery_item_fullscreen, parent, false) as ImageView
view.setOnTouchListener { v, event ->
var result = true
//can scroll horizontally checks if there's still a part of the image
//that can be scrolled until you reach the edge
if (event.pointerCount >= 2 || v.canScrollHorizontally(1) && v.canScrollHorizontally(
-1
)
) {
//multi-touch event
result = when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// Disallow RecyclerView to intercept touch events.
parent.requestDisallowInterceptTouchEvent(true)
// Disable touch on view
false
}
MotionEvent.ACTION_UP -> {
// Allow RecyclerView to intercept touch events.
parent.requestDisallowInterceptTouchEvent(false)
true
}
else -> true
}
}
result
}
} else {
view = inflater.inflate(R.layout.gallery_item, parent, false) as ImageView
}
val view = inflater.inflate(R.layout.gallery_item, parent, false) as ImageView
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
if (detailView) {
(holder.view as TouchImageView).resetZoom()
}
val id = getItem(position).id
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=$id" +
if (detailView) {
"&size=1000"
} else {
"&height=${holder.view.height}"
}
val url = getItem(position).getUrl(height = holder.view.height)
holder.view.load(
url
) {
if (pageToLoad == position && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
listener(
onSuccess = { _, metadata ->
memoryKeys[id] = metadata.memoryCacheKey
if (pageToLoad == position) invokeLoadedListener(holder.view)
},
onError = { _, _ ->
if (!loaded && loadedListener != null && pageToLoad == position) {
loadedListener.invoke()
loaded = true
}
}
)
}
if (pageToLoad == position && imageCacheKey != null) {
// start transition immediately
if (pageToLoad == position) invokeLoadedListener(holder.view)
}
holder.view.transitionName = galleryTransitionName(position)
if (itemClickListener != null) {
holder.view.setOnClickListener {
itemClickListener.onItemClick(holder.view, position, memoryKeys[id])
}
}
}
private fun invokeLoadedListener(
view: ImageView
) {
if (!loaded && loadedListener != null) {
view.viewTreeObserver.addOnPreDrawListener(object :
ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
loadedListener.invoke()
return true
}
})
loaded = true
}
}
}
fun galleryTransitionName(position: Int) = "gallery_$position"
class ChargerPhotoDiffCallback : DiffUtil.ItemCallback<ChargerPhoto>() {
override fun areItemsTheSame(oldItem: ChargerPhoto, newItem: ChargerPhoto): Boolean {
return oldItem.id == newItem.id

View File

@@ -0,0 +1,216 @@
package net.vonforst.evmap.adapter
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LiveData
import com.car2go.maps.model.LatLng
import net.vonforst.evmap.R
import net.vonforst.evmap.autocomplete.*
import net.vonforst.evmap.containsAny
import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding
import net.vonforst.evmap.isDarkMode
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.RecentAutocompletePlace
import java.time.Instant
class PlaceAutocompleteAdapter(val context: Context, val location: LiveData<LatLng>) :
BaseAdapter(), Filterable {
private var resultList: List<AutocompletePlace>? = null
private val providers = getAutocompleteProviders(context)
private val typeItem = 0
private val typeAttribution = 1
private val maxItems = 6
private var currentProvider: AutocompleteProvider? = null
private val recents = AppDatabase.getInstance(context).recentAutocompletePlaceDao()
private var recentResults = mutableListOf<RecentAutocompletePlace>()
data class ViewHolder(val binding: ItemAutocompleteResultBinding)
override fun getCount(): Int {
return resultList?.let { it.size + 1 } ?: 0
}
override fun getItem(position: Int): AutocompletePlace? {
return if (position < resultList!!.size) resultList!![position] else null
}
override fun getItemViewType(position: Int): Int {
return if (position < resultList!!.size) typeItem else typeAttribution
}
override fun getViewTypeCount(): Int = 2
override fun getItemId(position: Int): Long {
return 0
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var view = convertView
if (getItemViewType(position) == typeItem) {
val viewHolder: ViewHolder
if (view == null) {
val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.item_autocomplete_result,
parent,
false
)
view = binding.root
viewHolder = ViewHolder(binding)
view.tag = viewHolder
} else {
viewHolder = view.tag as ViewHolder
}
val place = resultList!![position]
bindView(viewHolder, place)
} else if (getItemViewType(position) == typeAttribution) {
if (view == null) {
view = LayoutInflater.from(context)
.inflate(R.layout.item_autocomplete_attribution, parent, false)
}
(view as ImageView).apply {
setImageResource(currentProvider?.getAttributionImage(context.isDarkMode()) ?: 0)
contentDescription = context.getString(currentProvider?.getAttributionString() ?: 0)
}
}
return view!!
}
private fun bindView(
viewHolder: ViewHolder,
place: AutocompletePlace
) {
viewHolder.binding.item = place
}
override fun getFilter(): Filter {
return object : Filter() {
var delaySet = false
init {
if (PreferenceDataSource(context).searchProvider == "mapbox") {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
}
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
resultList = results?.values as? List<AutocompletePlace>?
if (results != null && results.count > 0) {
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint.toString()
var resultList: List<AutocompletePlace>? = null
if (constraint != null) {
for (provider in providers) {
try {
recentResults.clear()
currentProvider = provider
// first search in recent places
val recentPlaces = if (query.isEmpty()) {
recents.getAll(provider.id, limit = maxItems)
} else {
recents.search(query, provider.id, limit = maxItems)
}
recentResults.addAll(recentPlaces)
resultList = recentPlaces.map { it.asAutocompletePlace(location.value) }
Handler(Looper.getMainLooper()).post {
// publish intermediate results on main thread
publishResults(constraint, resultList.asFilterResults())
}
// if we already have enough results or the query is short, stop here
if (isShortQuery(query) || recentResults.size >= maxItems) break
// then search online
val recentIds = recentPlaces.map { it.id }
resultList =
(resultList!! + provider.autocomplete(query, location.value)
.filter { !recentIds.contains(it.id) }).take(maxItems)
break
} catch (e: ApiUnavailableException) {
e.printStackTrace()
}
}
}
if (currentProvider is MapboxAutocompleteProvider && !delaySet) {
// set delay to 500 ms to reduce paid Mapbox API requests
this.setDelayer { q -> if (isShortQuery(q)) 0L else 500L }
}
return resultList.asFilterResults()
}
private fun List<AutocompletePlace>?.asFilterResults(): FilterResults {
val result = FilterResults()
if (this != null) {
result.values = this
result.count = this.size
}
return result
}
}
}
private fun isShortQuery(query: CharSequence) = query.length < 3
suspend fun getDetails(id: String): PlaceWithBounds {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()
val details = provider.getDetails(id)
recents.insert(RecentAutocompletePlace(result, details, provider.id, Instant.now()))
return details
}
}
fun iconForPlaceType(types: List<AutocompletePlaceType>): Int =
when {
types.contains(
AutocompletePlaceType.RECENT
) -> R.drawable.ic_history
types.containsAny(
AutocompletePlaceType.LIGHT_RAIL_STATION,
AutocompletePlaceType.BUS_STATION,
AutocompletePlaceType.TRAIN_STATION,
AutocompletePlaceType.TRANSIT_STATION
) -> {
R.drawable.ic_place_type_train
}
types.contains(AutocompletePlaceType.AIRPORT) -> {
R.drawable.ic_place_type_airport
}
// TODO: extend this with icons for more place categories
else -> {
R.drawable.ic_place_type_default
}
}
fun isSpecialPlace(types: List<AutocompletePlaceType>): Boolean =
!setOf(
R.drawable.ic_place_type_default,
R.drawable.ic_history
).contains(iconForPlaceType(types))

View File

@@ -0,0 +1,68 @@
package net.vonforst.evmap.api
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.viewmodel.Resource
interface ChargepointApi<out T : ReferenceData> {
suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?
): Resource<List<ChargepointListItem>>
suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): Resource<List<ChargepointListItem>>
suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation>
suspend fun getReferenceData(): Resource<T>
fun getFilters(referenceData: ReferenceData, sp: StringProvider): List<Filter<FilterValue>>
fun getName(): String
}
interface StringProvider {
fun getString(id: Int): String
}
fun Context.stringProvider() = object : StringProvider {
override fun getString(id: Int): String {
return this@stringProvider.getString(id)
}
}
fun createApi(type: String, ctx: Context): ChargepointApi<ReferenceData> {
return when (type) {
"openchargemap" -> {
OpenChargeMapApiWrapper(
ctx.getString(
R.string.openchargemap_key
)
)
}
"goingelectric" -> {
GoingElectricApiWrapper(
ctx.getString(
R.string.goingelectric_key
)
)
}
else -> throw IllegalArgumentException()
}
}

View File

@@ -1,13 +1,17 @@
package net.vonforst.evmap.api
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.json.JSONArray
import java.io.IOException
import kotlin.coroutines.resumeWithException
import kotlin.math.abs
operator fun <T> JSONArray.iterator(): Iterator<T> =
(0 until length()).asSequence().map {
@@ -39,26 +43,69 @@ suspend fun Call.await(): Response {
}
}
const val earthRadiusKm: Double = 6372.8
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,
Chargepoint.TYPE_2_PLUG to R.string.plug_type_2,
Chargepoint.TYPE_2_SOCKET to R.string.plug_type_2,
Chargepoint.TYPE_3 to R.string.plug_type_3,
Chargepoint.CCS_UNKNOWN to R.string.plug_ccs,
Chargepoint.CCS_TYPE_1 to R.string.plug_ccs,
Chargepoint.CCS_TYPE_2 to R.string.plug_ccs,
Chargepoint.SCHUKO to R.string.plug_schuko,
Chargepoint.CHADEMO to R.string.plug_chademo,
Chargepoint.SUPERCHARGER to R.string.plug_supercharger,
Chargepoint.CEE_BLAU to R.string.plug_cee_blau,
Chargepoint.CEE_ROT to R.string.plug_cee_rot,
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
)
/**
* Calculates the distance between two points on Earth in meters.
* Latitude and longitude should be given in degrees.
*/
fun distanceBetween(
startLatitude: Double, startLongitude: Double,
endLatitude: Double, endLongitude: Double
): Double {
// see https://rosettacode.org/wiki/Haversine_formula#Java
val dLat = Math.toRadians(endLatitude - startLatitude);
val dLon = Math.toRadians(endLongitude - startLongitude);
val originLat = Math.toRadians(startLatitude);
val destinationLat = Math.toRadians(endLatitude);
fun nameForPlugType(ctx: StringProvider, type: String): String =
plugNames[type]?.let {
ctx.getString(it)
} ?: type
val a = Math.pow(Math.sin(dLat / 2), 2.toDouble()) + Math.pow(
Math.sin(dLon / 2),
2.toDouble()
) * Math.cos(originLat) * Math.cos(destinationLat);
val c = 2 * Math.asin(Math.sqrt(a));
return earthRadiusKm * c * 1000;
}
fun equivalentPlugTypes(type: String): Set<String> {
return when (type) {
Chargepoint.CCS_TYPE_1 -> setOf(Chargepoint.CCS_UNKNOWN, Chargepoint.CCS_TYPE_1)
Chargepoint.CCS_TYPE_2 -> setOf(Chargepoint.CCS_UNKNOWN, Chargepoint.CCS_TYPE_2)
Chargepoint.CCS_UNKNOWN -> setOf(
Chargepoint.CCS_UNKNOWN,
Chargepoint.CCS_TYPE_1,
Chargepoint.CCS_TYPE_2
)
Chargepoint.TYPE_2_PLUG -> setOf(Chargepoint.TYPE_2_UNKNOWN, Chargepoint.TYPE_2_PLUG)
Chargepoint.TYPE_2_SOCKET -> setOf(Chargepoint.TYPE_2_UNKNOWN, Chargepoint.TYPE_2_SOCKET)
Chargepoint.TYPE_2_UNKNOWN -> setOf(
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_PLUG,
Chargepoint.TYPE_2_SOCKET
)
else -> setOf(type)
}
}
@DrawableRes
fun iconForPlugType(type: String): Int =
when (type) {
Chargepoint.CCS_TYPE_2 -> R.drawable.ic_connector_ccs_typ2
Chargepoint.CCS_UNKNOWN -> R.drawable.ic_connector_ccs_typ2
Chargepoint.CCS_TYPE_1 -> R.drawable.ic_connector_ccs_typ1
Chargepoint.CHADEMO -> R.drawable.ic_connector_chademo
Chargepoint.SCHUKO -> R.drawable.ic_connector_schuko
Chargepoint.SUPERCHARGER -> R.drawable.ic_connector_supercharger
Chargepoint.TYPE_2_UNKNOWN -> R.drawable.ic_connector_typ2
Chargepoint.TYPE_2_SOCKET -> R.drawable.ic_connector_typ2
Chargepoint.TYPE_2_PLUG -> R.drawable.ic_connector_typ2
Chargepoint.CEE_BLAU -> R.drawable.ic_connector_cee_blau
Chargepoint.CEE_ROT -> R.drawable.ic_connector_cee_rot
Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1
// TODO: add other connectors
else -> 0
}
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
fun mapPower(i: Int) = powerSteps[i]
fun mapPowerInverse(power: Int) = powerSteps
.mapIndexed { index, v -> abs(v - power) to index }
.minByOrNull { it.first }?.second ?: 0

View File

@@ -6,8 +6,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.cartesianProduct
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.viewmodel.Resource
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
@@ -63,10 +65,21 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
connectors: Map<Long, Pair<Double, String>>,
chargepoints: List<Chargepoint>
): Map<Chargepoint, Set<Long>> {
var chargepoints = chargepoints
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val geTypes = chargepoints.map { it.type }.distinct().toSet()
if (types != geTypes) throw AvailabilityDetectorException("chargepoints do not match")
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
var geTypes = chargepoints.map { it.type }.distinct().toSet()
if (!equivalentTypes.any { it == geTypes } && geTypes.size > 1 && geTypes.contains(
Chargepoint.SCHUKO
)) {
// If charger has household plugs and other plugs, try removing the household plugs
// (common e.g. in Hamburg -> 2x Type 2 + 2x Schuko, but NM only lists Type 2)
geTypes = geTypes.filter { it != Chargepoint.SCHUKO }.toSet()
chargepoints = chargepoints.filter { it.type != Chargepoint.SCHUKO }
}
if (!equivalentTypes.any { it == geTypes }) throw AvailabilityDetectorException("chargepoints do not match")
return types.flatMap { type ->
// find connectors of this type
val connsOfType = connectors.filter { it.value.second == type }
@@ -74,13 +87,14 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
val powers = connsOfType.map { it.value.first }.distinct().sorted()
// find corresponding powers in GE data
val gePowers =
chargepoints.filter { it.type == type }.map { it.power }.distinct().sorted()
chargepoints.filter { equivalentPlugTypes(it.type).any { it == type } }
.map { it.power }.distinct().sorted()
// if the distinct number of powers is the same, try to match.
if (powers.size == gePowers.size) {
gePowers.zip(powers).map { (gePower, power) ->
val chargepoint =
chargepoints.find { it.type == type && it.power == gePower }!!
chargepoints.find { equivalentPlugTypes(it.type).any { it == type } && it.power == gePower }!!
val ids = connsOfType.filter { it.value.first == power }.keys
if (chargepoint.count != ids.size) {
throw AvailabilityDetectorException("chargepoints do not match")
@@ -88,7 +102,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
chargepoint to ids
}
} else if (powers.size == 1 && gePowers.size == 2
&& chargepoints.sumBy { it.count } == connsOfType.size
&& chargepoints.sumOf { it.count } == connsOfType.size
) {
// special case: dual charger(s) with load balancing
// GoingElectric shows 2 different powers, NewMotion just one
@@ -96,7 +110,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
var i = 0
gePowers.map { gePower ->
val chargepoint =
chargepoints.find { it.type == type && it.power == gePower }!!
chargepoints.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
val ids = allIds.subList(i, i + chargepoint.count).toSet()
i += chargepoint.count
chargepoint to ids
@@ -113,7 +127,19 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
)
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
(connectors == null || connectors.map {
equivalentPlugTypes(it)
}.any { equivalent -> it.type in equivalent })
&& (minPower == null || it.power > minPower)
}
return this.copy(status = statusFiltered)
}
val totalChargepoints = status.map { it.key.count }.sum()
}
enum class ChargepointStatus {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.availability
import kotlinx.coroutines.ExperimentalCoroutinesApi
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.iterator
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException
@@ -85,9 +85,9 @@ class ChargecloudAvailabilityDetector(
private fun getType(string: String): String {
return when (string) {
"IEC_62196_T2" -> Chargepoint.TYPE_2
"IEC_62196_T2" -> Chargepoint.TYPE_2_UNKNOWN
"DOMESTIC_F" -> Chargepoint.SCHUKO
"IEC_62196_T2_COMBO" -> Chargepoint.CCS
"IEC_62196_T2_COMBO" -> Chargepoint.CCS_TYPE_2
"CHADEMO" -> Chargepoint.CHADEMO
else -> throw IllegalArgumentException("unrecognized type $string")
}

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.api.distanceBetween
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -141,16 +141,16 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
"type3" -> Chargepoint.TYPE_3
"type2" -> Chargepoint.TYPE_2
"type2" -> Chargepoint.TYPE_2_UNKNOWN
"type1" -> Chargepoint.TYPE_1
"domestic" -> Chargepoint.SCHUKO
"type1combo" -> Chargepoint.CCS // US CCS, aka type1_combo
"type2combo" -> Chargepoint.CCS // EU CCS, aka type2_combo
"type1combo" -> Chargepoint.CCS_TYPE_1 // US CCS, aka type1_combo
"type2combo" -> Chargepoint.CCS_TYPE_2 // EU CCS, aka type2_combo
"tepcochademo" -> Chargepoint.CHADEMO
"unspecified" -> "unknown"
"unknown" -> "unknown"
"saej1772" -> "unknown"
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
else -> "unknown"
}
val status = when (statusStr) {
"Unavailable" -> ChargepointStatus.FAULTED

View File

@@ -0,0 +1,138 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import moe.banana.jsonapi2.ArrayDocument
import moe.banana.jsonapi2.JsonApiConverterFactory
import moe.banana.jsonapi2.ResourceAdapterFactory
import net.vonforst.evmap.BuildConfig
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import java.util.*
interface ChargepriceApi {
@POST("charge_prices")
suspend fun getChargePrices(
@Body request: ChargepriceRequest,
@Header("Accept-Language") language: String
): ArrayDocument<ChargePrice>
@GET("vehicles")
suspend fun getVehicles(): ArrayDocument<ChargepriceCar>
@GET("tariffs")
suspend fun getTariffs(): ArrayDocument<ChargepriceTariff>
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
val supportedLanguages = setOf("de", "en", "fr", "nl")
val DATA_SOURCE_GOINGELECTRIC = "going_electric"
val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = ResourceAdapterFactory.builder()
.add(ChargepriceRequest::class.java)
.add(ChargepriceTariff::class.java)
.add(ChargepriceBrand::class.java)
.add(ChargePrice::class.java)
.add(ChargepriceCar::class.java)
.build()
val moshi = Moshi.Builder()
.add(jsonApiAdapterFactory)
.add(KotlinJsonAdapterFactory())
.build()
fun create(
apikey: String,
baseurl: String = "https://api.chargeprice.app/v1/",
context: Context? = null
): ChargepriceApi {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
val original = chain.request()
val new = original.newBuilder()
.header("API-Key", 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(JsonApiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(ChargepriceApi::class.java)
}
fun getChargepriceLanguage(): String {
val locale = Locale.getDefault().language
return if (supportedLanguages.contains(locale)) {
locale
} else {
"en"
}
}
@JvmStatic
fun isCountrySupported(country: String, dataSource: String): Boolean = when (dataSource) {
// list of countries updated 2021/08/24
"goingelectric" -> country in listOf(
"Deutschland",
"Österreich",
"Schweiz",
"Frankreich",
"Belgien",
"Niederlande",
"Luxemburg",
"Dänemark",
"Norwegen",
"Schweden",
"Slowenien",
"Kroatien",
"Ungarn",
"Tschechien",
"Italien",
"Spanien",
"Großbritannien",
"Irland"
)
"openchargemap" -> country in listOf(
"DE",
"AT",
"CH",
"FR",
"BE",
"NE",
"LU",
"DK",
"NO",
"SE",
"SI",
"HR",
"HU",
"CZ",
"IT",
"ES",
"GB",
"IE"
)
else -> false
}
}
}

View File

@@ -0,0 +1,372 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.squareup.moshi.Json
import moe.banana.jsonapi2.HasMany
import moe.banana.jsonapi2.HasOne
import moe.banana.jsonapi2.JsonApi
import moe.banana.jsonapi2.Resource
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor
@JsonApi(type = "charge_price_request")
class ChargepriceRequest : Resource() {
@field:Json(name = "data_adapter")
lateinit var dataAdapter: String
lateinit var station: ChargepriceStation
lateinit var options: ChargepriceOptions
var tariffs: HasMany<ChargepriceTariff>? = null
var vehicle: HasOne<ChargepriceCar>? = null
}
data class ChargepriceStation(
val longitude: Double,
val latitude: Double,
val country: String?,
val network: String?,
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepoint>
) {
companion object {
fun fromEvmap(
charger: ChargeLocation,
compatibleConnectors: List<String>,
): ChargepriceStation {
if (charger.chargepriceData == null) throw IllegalArgumentException()
val plugTypes =
charger.chargepriceData.plugTypes ?: charger.chargepoints.map { it.type }
return ChargepriceStation(
charger.coordinates.lng,
charger.coordinates.lat,
charger.chargepriceData.country,
charger.chargepriceData.network,
charger.chargepoints.zip(plugTypes).filter {
equivalentPlugTypes(it.first.type).any { it in compatibleConnectors }
}.map {
ChargepriceChargepoint(it.first.power, it.second)
}
)
}
}
}
data class ChargepriceChargepoint(
val power: Double,
val plug: String
)
data class ChargepriceOptions(
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
val energy: Double? = null,
val duration: Int? = null,
@Json(name = "battery_range") val batteryRange: List<Double>? = null,
@Json(name = "car_ac_phases") val carAcPhases: Int? = null,
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
)
@JsonApi(type = "tariff")
class ChargepriceTariff() : Resource() {
lateinit var provider: String
lateinit var name: String
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
@field:Json(name = "supported_cuntries")
lateinit var supportedCountries: Set<String>
@field:Json(name = "charge_card_id")
lateinit var chargeCardId: String // GE charge card ID
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceTariff
if (provider != other.provider) return false
if (name != other.name) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (supportedCountries != other.supportedCountries) return false
if (chargeCardId != other.chargeCardId) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + supportedCountries.hashCode()
result = 31 * result + chargeCardId.hashCode()
return result
}
}
@JsonApi(type = "car")
class ChargepriceCar : Resource(), Equatable {
lateinit var name: String
lateinit var brand: String
@field:Json(name = "dc_charge_ports")
lateinit var dcChargePorts: List<String>
lateinit var manufacturer: HasOne<ChargepriceBrand>
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargepriceCar
if (name != other.name) return false
if (brand != other.brand) return false
if (dcChargePorts != other.dcChargePorts) return false
if (manufacturer != other.manufacturer) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + brand.hashCode()
result = 31 * result + dcChargePorts.hashCode()
result = 31 * result + manufacturer.hashCode()
return result
}
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.map {
plugMapping[it]
}.filterNotNull().plus(acConnectors)
}
@JsonApi(type = "brand")
class ChargepriceBrand : Resource()
@JsonApi(type = "charge_price")
class ChargePrice : Resource(), Equatable, Cloneable {
lateinit var provider: String
@field:Json(name = "tariff_name")
lateinit var tariffName: String
lateinit var url: String
@field:Json(name = "monthly_min_sales")
var monthlyMinSales: Double = 0.0
@field:Json(name = "total_monthly_fee")
var totalMonthlyFee: Double = 0.0
@field:Json(name = "flat_rate")
var flatRate: Boolean = false
@field:Json(name = "direct_payment")
var directPayment: Boolean = false
@field:Json(name = "provider_customer_tariff")
var providerCustomerTariff: Boolean = false
lateinit var currency: String
@field:Json(name = "start_time")
var startTime: Int = 0
lateinit var tags: List<ChargepriceTag>
@field:Json(name = "charge_point_prices")
lateinit var chargepointPrices: List<ChargepointPrice>
@field:Json(name = "branding")
var branding: ChargepriceBranding? = null
var tariff: HasOne<ChargepriceTariff>? = null
fun formatMonthlyFees(ctx: Context): String {
return listOfNotNull(
if (totalMonthlyFee > 0) {
ctx.getString(R.string.chargeprice_base_fee, totalMonthlyFee, currency(currency))
} else null,
if (monthlyMinSales > 0) {
ctx.getString(R.string.chargeprice_min_spend, monthlyMinSales, currency(currency))
} else null
).joinToString(", ")
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as ChargePrice
if (provider != other.provider) return false
if (tariffName != other.tariffName) return false
if (url != other.url) return false
if (monthlyMinSales != other.monthlyMinSales) return false
if (totalMonthlyFee != other.totalMonthlyFee) return false
if (flatRate != other.flatRate) return false
if (directPayment != other.directPayment) return false
if (providerCustomerTariff != other.providerCustomerTariff) return false
if (currency != other.currency) return false
if (startTime != other.startTime) return false
if (tags != other.tags) return false
if (chargepointPrices != other.chargepointPrices) return false
if (branding != other.branding) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + provider.hashCode()
result = 31 * result + tariffName.hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + monthlyMinSales.hashCode()
result = 31 * result + totalMonthlyFee.hashCode()
result = 31 * result + flatRate.hashCode()
result = 31 * result + directPayment.hashCode()
result = 31 * result + providerCustomerTariff.hashCode()
result = 31 * result + currency.hashCode()
result = 31 * result + startTime
result = 31 * result + tags.hashCode()
result = 31 * result + chargepointPrices.hashCode()
result = 31 * result + branding.hashCode()
return result
}
public override fun clone(): ChargePrice {
return ChargePrice().apply {
chargepointPrices = this@ChargePrice.chargepointPrices
currency = this@ChargePrice.currency
directPayment = this@ChargePrice.directPayment
flatRate = this@ChargePrice.flatRate
monthlyMinSales = this@ChargePrice.monthlyMinSales
provider = this@ChargePrice.provider
providerCustomerTariff = this@ChargePrice.providerCustomerTariff
startTime = this@ChargePrice.startTime
tags = this@ChargePrice.tags
tariffName = this@ChargePrice.tariffName
totalMonthlyFee = this@ChargePrice.totalMonthlyFee
url = this@ChargePrice.url
tariff = this@ChargePrice.tariff
branding = this@ChargePrice.branding
}
}
}
data class ChargepointPrice(
val power: Double,
val plug: String,
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?
) {
fun formatDistribution(ctx: Context): String {
fun percent(value: Double): String {
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
if (h == 0 && min > 0) return "${min}min";
// be slightly sloppy (3:01 is shown as 3h) to save space
else if (h > 0 && (min == 0 || min == 1)) return "${h}h";
else return "%d:%02dh".format(h, min);
}
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js
with(priceDistribution) {
return listOfNotNull(
if (session != null && session > 0.0) {
(if (session < 1) percent(session) else "") + ctx.getString(R.string.chargeprice_session_fee)
} else null,
if (kwh != null && kwh > 0.0 && !isOnlyKwh) {
(if (kwh < 1) percent(kwh) else "") + ctx.getString(R.string.chargeprice_per_kwh)
} else null,
if (minute != null && minute > 0.0) {
(if (minute < 1) percent(minute) else "") + ctx.getString(R.string.chargeprice_per_minute) +
if (blockingFeeStart != null) {
" (${
ctx.getString(
R.string.chargeprice_blocking_fee,
time(blockingFeeStart)
)
})"
} else ""
} else null,
if ((minute == null || minute == 0.0) && blockingFeeStart != null) {
ctx.getString(R.string.chargeprice_blocking_fee, time(blockingFeeStart))
} else null
).joinToString(" +\u00a0")
}
}
}
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
)
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) {
val isOnlyKwh =
kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
}
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable
data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
enum class ChargepriceInclude {
@Json(name = "filter")
FILTER,
@Json(name = "always")
ALWAYS,
@Json(name = "exclusive")
EXCLUSIVE
}
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,
val energy: Double,
val duration: Double
)

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.api.goingelectric
import android.util.Log
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.time.Instant
@@ -12,7 +13,7 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
annotations: MutableSet<out Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (Types.getRawType(type) == ChargepointListItem::class.java) {
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
return ChargepointListItemJsonAdapter(
moshi
)
@@ -25,18 +26,18 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
JsonAdapter<ChargepointListItem>() {
JsonAdapter<GEChargepointListItem>() {
private val clusterAdapter =
moshi.adapter<ChargeLocationCluster>(
ChargeLocationCluster::class.java
moshi.adapter<GEChargeLocationCluster>(
GEChargeLocationCluster::class.java
)
private val locationAdapter = moshi.adapter<ChargeLocation>(
ChargeLocation::class.java
private val locationAdapter = moshi.adapter<GEChargeLocation>(
GEChargeLocation::class.java
)
@FromJson
override fun fromJson(reader: JsonReader): ChargepointListItem {
override fun fromJson(reader: JsonReader): GEChargepointListItem {
var clustered = false
reader.peekJson().use { peeked ->
peeked.beginObject()
@@ -60,7 +61,7 @@ internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
val CLUSTERED: JsonReader.Options = JsonReader.Options.of("clustered")
}
override fun toJson(writer: JsonWriter, value: ChargepointListItem?) {
override fun toJson(writer: JsonWriter, value: GEChargepointListItem?) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}
@@ -93,8 +94,8 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null // Response was false
else -> {
if (this.clazz == FaultReport::class.java) {
FaultReport(null, null) as T
if (this.clazz == GEFaultReport::class.java) {
GEFaultReport(null, null) as T
} else {
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
}
@@ -125,21 +126,28 @@ internal class HoursAdapter {
private val regex = Regex("from (.*) till (.*)")
@FromJson
fun fromJson(str: String): Hours? {
fun fromJson(str: String): GEHours? {
if (str == "closed") {
return Hours(null, null)
return GEHours(null, null)
} else {
val match = regex.find(str)
?: throw IllegalArgumentException("$str does not match hours format")
return Hours(
LocalTime.parse(match.groupValues[1]),
LocalTime.parse(match.groupValues[2])
)
if (match != null) {
return GEHours(
LocalTime.parse(match.groupValues[1]),
LocalTime.parse(match.groupValues[2])
)
} else {
// I cannot reproduce this case, but it seems to occur once in a while
Log.e("GoingElectricApi", "invalid hours value: " + str)
return GEHours(
LocalTime.MIN, LocalTime.MIN
)
}
}
}
@ToJson
fun toJson(value: Hours): String {
fun toJson(value: GEHours): String {
if (value.start == null || value.end == null) {
return "closed"
} else {

View File

@@ -1,22 +1,34 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
interface GoingElectricApi {
@GET("chargepoints/")
suspend fun getChargepoints(
@Query("sw_lat") swlat: Double, @Query("sw_lng") sw_lng: Double,
@Query("sw_lat") sw_lat: Double, @Query("sw_lng") sw_lng: Double,
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
@Query("zoom") zoom: Float,
@Query("clustering") clustering: Boolean = false,
@@ -32,23 +44,51 @@ interface GoingElectricApi {
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
): Response<ChargepointList>
): Response<GEChargepointList>
@GET("chargepoints/")
fun getChargepointDetail(@Query("ge_id") id: Long): Call<ChargepointList>
suspend fun getChargepointsRadius(
@Query("lat") lat: Double, @Query("lng") lng: Double,
@Query("radius") radius: Int,
@Query("zoom") zoom: Float,
@Query("orderby") orderby: String = "distance",
@Query("clustering") clustering: Boolean = false,
@Query("cluster_distance") clusterDistance: Int? = null,
@Query("freecharging") freecharging: Boolean = false,
@Query("freeparking") freeparking: Boolean = false,
@Query("min_power") minPower: Int = 0,
@Query("plugs") plugs: String? = null,
@Query("chargecards") chargecards: String? = null,
@Query("networks") networks: String? = null,
@Query("categories") categories: String? = null,
@Query("startkey") startkey: Int? = null,
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
): Response<GEChargepointList>
@GET("chargepoints/")
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<GEChargepointList>
@GET("chargepoints/pluglist/")
suspend fun getPlugs(): Response<StringList>
suspend fun getPlugs(): Response<GEStringList>
@GET("chargepoints/networklist/")
suspend fun getNetworks(): Response<StringList>
suspend fun getNetworks(): Response<GEStringList>
@GET("chargepoints/chargecardlist/")
suspend fun getChargeCards(): Response<ChargeCardList>
suspend fun getChargeCards(): Response<GEChargeCardList>
companion object {
private val cacheSize = 10L * 1024 * 1024 // 10MB
private val moshi = Moshi.Builder()
.add(ChargepointListItemJsonAdapterFactory())
.add(JsonObjectOrFalseAdapter.Factory())
.add(HoursAdapter())
.add(InstantAdapter())
.build()
fun create(
apikey: String,
baseurl: String = "https://api.goingelectric.de",
@@ -70,13 +110,6 @@ interface GoingElectricApi {
}
}.build()
val moshi = Moshi.Builder()
.add(ChargepointListItemJsonAdapterFactory())
.add(JsonObjectOrFalseAdapter.Factory())
.add(HoursAdapter())
.add(InstantAdapter())
.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(MoshiConverterFactory.create(moshi))
@@ -86,3 +119,364 @@ interface GoingElectricApi {
}
}
}
class GoingElectricApiWrapper(
val apikey: String,
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
) : ChargepointApi<GEReferenceData> {
val api = GoingElectricApi.create(apikey, baseurl, context)
override fun getName() = "GoingElectric.de"
override suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
val freecharging = filters?.getBooleanValue("freecharging")
val freeparking = filters?.getBooleanValue("freeparking")
val open247 = filters?.getBooleanValue("open_247")
val barrierfree = filters?.getBooleanValue("barrierfree")
val excludeFaults = filters?.getBooleanValue("exclude_faults")
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = filters?.getMultipleChoiceValue("networks")
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters?.getMultipleChoiceValue("categories")
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Resource.success(emptyList())
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
var startkey: Int? = null
val data = mutableListOf<GEChargepointListItem>()
do {
// load all pages of the response
try {
val response = api.getChargepoints(
bounds.southwest.latitude,
bounds.southwest.longitude,
bounds.northeast.latitude,
bounds.northeast.longitude,
clustering = useGeClustering,
zoom = zoom,
clusterDistance = clusterDistance,
freecharging = freecharging ?: false,
minPower = minPower ?: 0,
freeparking = freeparking ?: false,
open247 = open247 ?: false,
barrierfree = barrierfree ?: false,
excludeFaults = excludeFaults ?: false,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
var result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
return Resource.success(result)
}
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
if (value == null || value.all) null else value.values.joinToString(",")
override suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
val freecharging = filters?.getBooleanValue("freecharging")
val freeparking = filters?.getBooleanValue("freeparking")
val open247 = filters?.getBooleanValue("open_247")
val barrierfree = filters?.getBooleanValue("barrierfree")
val excludeFaults = filters?.getBooleanValue("exclude_faults")
val minPower = filters?.getSliderValue("min_power")
val minConnectors = filters?.getSliderValue("min_connectors")
var connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters?.getMultipleChoiceValue("chargecards")
if (chargeCardsVal != null && chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = filters?.getMultipleChoiceValue("networks")
if (networksVal != null && networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters?.getMultipleChoiceValue("categories")
if (categoriesVal != null && categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Resource.success(emptyList())
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
var startkey: Int? = null
val data = mutableListOf<GEChargepointListItem>()
do {
// load all pages of the response
try {
val response = api.getChargepointsRadius(
location.latitude, location.longitude, radius,
clustering = useGeClustering,
zoom = zoom,
clusterDistance = clusterDistance,
freecharging = freecharging ?: false,
minPower = minPower ?: 0,
freeparking = freeparking ?: false,
open247 = open247 ?: false,
barrierfree = barrierfree ?: false,
excludeFaults = excludeFaults ?: false,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
val result = postprocessResult(data, minPower, connectorsVal, minConnectors, zoom)
return Resource.success(result)
}
private fun postprocessResult(
chargers: List<GEChargepointListItem>,
minPower: Int?,
connectorsVal: MultipleChoiceFilterValue?,
minConnectors: Int?,
zoom: Float
): List<ChargepointListItem> {
// apply filters which GoingElectric does not support natively
var result = chargers.filter { it ->
if (it is GEChargeLocation) {
it.chargepoints
.filter { it.power >= (minPower ?: 0) }
.filter { if (connectorsVal != null && !connectorsVal.all) it.type in connectorsVal.values else true }
.sumOf { it.count } >= (minConnectors ?: 0)
} else {
true
}
}.map { it.convert(apikey) }
// apply clustering
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors == null || minConnectors <= 1
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
if (!geClusteringAvailable && useClustering) {
// apply local clustering if server side clustering is not available
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return result
}
override suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
try {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
apikey
)
)
} else {
Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getReferenceData(): Resource<GEReferenceData> =
withContext(Dispatchers.IO) {
supervisorScope {
try {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
}
} catch (e: IOException) {
Resource.error(e.message, null)
}
}
}
override fun getFilters(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val referenceData = referenceData as GEReferenceData
val plugs = referenceData.plugs
val networks = referenceData.networks
val chargeCards = referenceData.chargecards
val plugMap = plugs.map { plug ->
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
}.toMap()
val networkMap = networks.map { it to it }.toMap()
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
val categoryMap = mapOf(
"Autohaus" to sp.getString(R.string.category_car_dealership),
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
"Autohof" to sp.getString(R.string.category_service_off_motorway),
"Bahnhof" to sp.getString(R.string.category_railway_station),
"Behörde" to sp.getString(R.string.category_public_authorities),
"Campingplatz" to sp.getString(R.string.category_camping),
"Einkaufszentrum" to sp.getString(R.string.category_shopping_mall),
"Ferienwohnung" to sp.getString(R.string.category_holiday_home),
"Flughafen" to sp.getString(R.string.category_airport),
"Freizeitpark" to sp.getString(R.string.category_amusement_park),
"Hotel" to sp.getString(R.string.category_hotel),
"Kino" to sp.getString(R.string.category_cinema),
"Kirche" to sp.getString(R.string.category_church),
"Krankenhaus" to sp.getString(R.string.category_hospital),
"Museum" to sp.getString(R.string.category_museum),
"Parkhaus" to sp.getString(R.string.category_parking_multi),
"Parkplatz" to sp.getString(R.string.category_parking),
"Privater Ladepunkt" to sp.getString(R.string.category_private_charger),
"Rastplatz" to sp.getString(R.string.category_rest_area),
"Restaurant" to sp.getString(R.string.category_restaurant),
"Schwimmbad" to sp.getString(R.string.category_swimming_pool),
"Supermarkt" to sp.getString(R.string.category_supermarket),
"Tankstelle" to sp.getString(R.string.category_petrol_station),
"Tiefgarage" to sp.getString(R.string.category_parking_underground),
"Tierpark" to sp.getString(R.string.category_zoo),
"Wohnmobilstellplatz" to sp.getString(R.string.category_caravan_site)
)
return listOf(
BooleanFilter(sp.getString(R.string.filter_free), "freecharging"),
BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"),
SliderFilter(
sp.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = listOf(
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.CCS_UNKNOWN,
Chargepoint.CHADEMO
).map { GEChargepoint.convertTypeToGE(it)!! }.toSet(),
manyChoices = true
),
SliderFilter(
sp.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
MultipleChoiceFilter(
sp.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
sp.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
sp.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults")
)
}
}

View File

@@ -1,301 +1,249 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import android.os.Parcelable
import androidx.core.text.HtmlCompat
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
import net.vonforst.evmap.model.*
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
@JsonClass(generateAdapter = true)
data class ChargepointList(
data class GEChargepointList(
val status: String,
val chargelocations: List<ChargepointListItem>,
val chargelocations: List<GEChargepointListItem>,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
data class StringList(
data class GEStringList(
val status: String,
val result: List<String>
)
@JsonClass(generateAdapter = true)
data class ChargeCardList(
data class GEChargeCardList(
val status: String,
val result: List<ChargeCard>
val result: List<GEChargeCard>
)
sealed class ChargepointListItem
sealed class GEChargepointListItem {
abstract fun convert(apikey: String): ChargepointListItem
}
@JsonClass(generateAdapter = true)
@Entity
data class ChargeLocation(
@Json(name = "ge_id") @PrimaryKey val id: Long,
data class GEChargeLocation(
@Json(name = "ge_id") val id: Long,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
val chargepoints: List<Chargepoint>,
val coordinates: GECoordinate,
val address: GEAddress,
val chargepoints: List<GEChargepoint>,
@JsonObjectOrFalse val network: String?,
val url: String,
@Embedded(prefix = "fault_report_") @JsonObjectOrFalse @Json(name = "fault_report") val faultReport: FaultReport?,
@JsonObjectOrFalse @Json(name = "fault_report") val faultReport: GEFaultReport?,
val verified: Boolean,
@Json(name = "barrierfree") val barrierFree: Boolean?,
// only shown in details:
@JsonObjectOrFalse val operator: String?,
@JsonObjectOrFalse @Json(name = "general_information") val generalInformation: String?,
@JsonObjectOrFalse @Json(name = "ladeweile") val amenities: String?,
@JsonObjectOrFalse @Json(name = "location_description") val locationDescription: String?,
val photos: List<ChargerPhoto>?,
@JsonObjectOrFalse val chargecards: List<ChargeCardId>?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?
) : ChargepointListItem(), Equatable {
/**
* maximum power available from this charger.
*/
val maxPower: Double
get() {
return maxPower()
}
/**
* Gets the maximum power available from certain connectors of this charger.
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.maxOrNull() ?: 0.0
}
/**
* Merges chargepoints if they have the same plug and power
*
* This occurs e.g. for Type2 sockets and plugs, which are distinct on the GE website, but not
* separable in the API
*/
val chargepointsMerged: List<Chargepoint>
get() {
val variants = chargepoints.distinctBy { it.power to it.type }
return variants.map { variant ->
val count = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
.sumBy { it.count }
Chargepoint(variant.type, variant.power, count)
}
}
val totalChargepoints: Int
get() = chargepoints.sumBy { it.count }
fun formatChargepoints(): String {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
}.joinToString(" · ")
}
val photos: List<GEChargerPhoto>?,
@JsonObjectOrFalse val chargecards: List<GEChargeCardId>?,
val openinghours: GEOpeningHours?,
val cost: GECost?
) : GEChargepointListItem() {
override fun convert(apikey: String) = ChargeLocation(
id,
"goingelectric",
name,
coordinates.convert(),
address.convert(),
chargepoints.map { it.convert() },
network,
"https:${url}",
"https:${url}edit/",
faultReport?.convert(),
verified,
barrierFree,
operator,
generalInformation,
amenities,
locationDescription,
photos?.map { it.convert(apikey) },
chargecards?.map { it.convert() },
openinghours?.convert(),
cost?.convert(),
null,
ChargepriceData(address.country, network, chargepoints.map { it.type })
)
}
@JsonClass(generateAdapter = true)
data class Cost(
data class GECost(
val freecharging: Boolean,
val freeparking: Boolean,
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
) {
fun getStatusText(ctx: Context): CharSequence {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.cost_detail,
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid),
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
), 0
)
}
fun convert() = Cost(
// In GE, freecharging = false can either mean "paid charging" or "no information
// available", only freecharging = true provides useful information. Therefore convert
// false to null. Same for freeparking.
if (freecharging) freecharging else null,
if (freeparking) freeparking else null,
descriptionShort, descriptionLong
)
}
@JsonClass(generateAdapter = true)
data class OpeningHours(
data class GEOpeningHours(
@Json(name = "24/7") val twentyfourSeven: Boolean,
@JsonObjectOrFalse val description: String?,
@Embedded val days: OpeningHoursDays?
val days: GEOpeningHoursDays?
) {
val isEmpty: Boolean
get() = description == "Leider noch keine Informationen zu Öffnungszeiten vorhanden."
&& days == null && !twentyfourSeven
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
} else if (days != null) {
val hours = days.getHoursForDate(LocalDate.now())
if (hours.start == null || hours.end == null) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
}
val now = LocalTime.now()
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.toString()
), 0
)
} else if (hours.end.isBefore(now)) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
} else {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.closed_opensat,
hours.start.toString()
), 0
)
}
} else {
return ""
}
}
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
}
@JsonClass(generateAdapter = true)
data class OpeningHoursDays(
@Embedded(prefix = "mo") val monday: Hours,
@Embedded(prefix = "tu") val tuesday: Hours,
@Embedded(prefix = "we") val wednesday: Hours,
@Embedded(prefix = "th") val thursday: Hours,
@Embedded(prefix = "fr") val friday: Hours,
@Embedded(prefix = "sa") val saturday: Hours,
@Embedded(prefix = "su") val sunday: Hours,
@Embedded(prefix = "ho") val holiday: Hours
data class GEOpeningHoursDays(
val monday: GEHours,
val tuesday: GEHours,
val wednesday: GEHours,
val thursday: GEHours,
val friday: GEHours,
val saturday: GEHours,
val sunday: GEHours,
val holiday: GEHours
) {
fun getHoursForDate(date: LocalDate): Hours {
// TODO: check for holidays
return getHoursForDayOfWeek(date.dayOfWeek)
}
fun getHoursForDayOfWeek(dayOfWeek: DayOfWeek?): Hours {
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
return when (dayOfWeek) {
DayOfWeek.MONDAY -> monday
DayOfWeek.TUESDAY -> tuesday
DayOfWeek.WEDNESDAY -> wednesday
DayOfWeek.THURSDAY -> thursday
DayOfWeek.FRIDAY -> friday
DayOfWeek.SATURDAY -> saturday
DayOfWeek.SUNDAY -> sunday
null -> holiday
}
}
fun convert() = OpeningHoursDays(
monday.convert(),
tuesday.convert(),
wednesday.convert(),
thursday.convert(),
friday.convert(),
saturday.convert(),
sunday.convert(),
holiday.convert()
)
}
data class Hours(
data class GEHours(
val start: LocalTime?,
val end: LocalTime?
) {
override fun toString(): String {
if (start != null && end != null) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return "${start.format(fmt)} - ${end.format(fmt)}"
} else {
return "closed"
}
}
fun convert() = if (start != null && end != null) Hours(start, end) else null
}
@JsonClass(generateAdapter = true)
data class GEChargerPhoto(val id: String) {
fun convert(apikey: String): ChargerPhoto = GEChargerPhotoAdapter(id, apikey)
}
@Parcelize
data class ChargerPhoto(val id: String) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
) : ChargepointListItem()
@JsonClass(generateAdapter = true)
data class Coordinate(val lat: Double, val lng: Double) {
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)
class GEChargerPhotoAdapter(override val id: String, val apikey: String) :
ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
return "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}&id=$id" +
when {
size != null -> "&size=$size"
height != null -> "&height=$height"
width != null -> "&width=$width"
else -> ""
}
}
}
@JsonClass(generateAdapter = true)
data class Address(
data class GEChargeLocationCluster(
val clusterCount: Int,
val coordinates: GECoordinate
) : GEChargepointListItem() {
override fun convert(apikey: String) =
ChargeLocationCluster(clusterCount, coordinates.convert())
}
@JsonClass(generateAdapter = true)
data class GECoordinate(val lat: Double, val lng: Double) {
fun convert() = Coordinate(lat, lng)
}
@JsonClass(generateAdapter = true)
data class GEAddress(
@JsonObjectOrFalse val city: String?,
@JsonObjectOrFalse val country: String?,
@JsonObjectOrFalse val postcode: String?,
@JsonObjectOrFalse val street: String?
) {
override fun toString(): String {
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
fun convert() = Address(city, country, postcode, street)
}
@JsonClass(generateAdapter = true)
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable {
fun formatPower(): String {
val powerFmt = if (power - power.toInt() == 0.0) {
"%.0f".format(power)
} else {
"%.1f".format(power)
}
return "$powerFmt kW"
}
data class GEChargepoint(val type: String, val power: Double, val count: Int) {
fun convert() = Chargepoint(convertTypeFromGE(type), power, count)
companion object {
const val TYPE_1 = "Typ1"
const val TYPE_2 = "Typ2"
const val TYPE_3 = "Typ3"
const val CCS = "CCS"
const val SCHUKO = "Schuko"
const val CHADEMO = "CHAdeMO"
const val SUPERCHARGER = "Tesla Supercharger"
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
const val TESLA_ROADSTER_HPC = "Tesla HPC"
fun convertTypeToGE(type: String): String? {
return when (type) {
Chargepoint.TYPE_1 -> "Typ1"
Chargepoint.TYPE_2_UNKNOWN -> "Typ2"
Chargepoint.TYPE_3 -> "Typ3"
Chargepoint.CCS_UNKNOWN -> "CCS"
Chargepoint.CCS_TYPE_2 -> "Typ2"
Chargepoint.SCHUKO -> "Schuko"
Chargepoint.CHADEMO -> "CHAdeMO"
Chargepoint.SUPERCHARGER -> "Tesla Supercharger"
Chargepoint.CEE_BLAU -> "CEE Blau"
Chargepoint.CEE_ROT -> "CEE Rot"
Chargepoint.TESLA_ROADSTER_HPC -> "Tesla HPC"
else -> null
}
}
fun convertTypeFromGE(type: String): String {
return when (type) {
"Typ1" -> Chargepoint.TYPE_1
"Typ2" -> Chargepoint.TYPE_2_UNKNOWN
"Typ3" -> Chargepoint.TYPE_3
"CCS" -> Chargepoint.CCS_UNKNOWN
"Schuko" -> Chargepoint.SCHUKO
"CHAdeMO" -> Chargepoint.CHADEMO
"Tesla Supercharger" -> Chargepoint.SUPERCHARGER
"CEE Blau" -> Chargepoint.CEE_BLAU
"CEE Rot" -> Chargepoint.CEE_ROT
"Tesla HPC" -> Chargepoint.TESLA_ROADSTER_HPC
else -> type
}
}
}
}
@JsonClass(generateAdapter = true)
data class FaultReport(val created: Instant?, val description: String?)
data class GEFaultReport(val created: Instant?, val description: String?) {
fun convert() = FaultReport(created, description)
}
@Entity
@JsonClass(generateAdapter = true)
data class ChargeCard(
@Entity
data class GEChargeCard(
@Json(name = "card_id") @PrimaryKey val id: Long,
val name: String,
val url: String
)
) {
fun convert() = ChargeCard(id, name, url)
}
@JsonClass(generateAdapter = true)
data class ChargeCardId(
data class GEChargeCardId(
val id: Long
)
) {
fun convert() = ChargeCardId(id)
}
data class GEReferenceData(
val plugs: List<String>,
val networks: List<String>,
val chargecards: List<GEChargeCard>
) : ReferenceData()

View File

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

View File

@@ -0,0 +1,328 @@
package net.vonforst.evmap.api.openchargemap
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.*
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
interface OpenChargeMapApi {
@GET("poi/")
suspend fun getChargepoints(
@Query("boundingbox") boundingbox: OCMBoundingBox,
@Query("connectiontypeid") plugs: String? = null,
@Query("minpowerkw") minPower: Double? = null,
@Query("operatorid") operators: String? = null,
@Query("statustypeid") statusType: String? = null,
@Query("maxresults") maxresults: Int = 500,
@Query("compact") compact: Boolean = true,
@Query("verbose") verbose: Boolean = false
): Response<List<OCMChargepoint>>
@GET("poi/")
suspend fun getChargepointsRadius(
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("distance") distance: Double,
@Query("distanceunit") distanceUnit: String = "KM",
@Query("connectiontypeid") plugs: String? = null,
@Query("minpowerkw") minPower: Double? = null,
@Query("operatorid") operators: String? = null,
@Query("statustypeid") statusType: String? = null,
@Query("maxresults") maxresults: Int = 500,
@Query("compact") compact: Boolean = true,
@Query("verbose") verbose: Boolean = false
): Response<List<OCMChargepoint>>
@GET("poi/")
suspend fun getChargepointDetail(
@Query("chargepointid") id: Long,
@Query("includecomments") includeComments: Boolean = true,
@Query("compact") compact: Boolean = false,
@Query("verbose") verbose: Boolean = false
): Response<List<OCMChargepoint>>
@GET("referencedata/")
suspend fun getReferenceData(): Response<OCMReferenceData>
companion object {
private val cacheSize = 10L * 1024 * 1024 // 10MB
private val moshi = Moshi.Builder()
.add(ZonedDateTimeAdapter())
.build()
fun create(
apikey: String,
baseurl: String = "https://api.openchargemap.io/v3/",
context: Context? = null
): OpenChargeMapApi {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
val original = chain.request()
val new = original.newBuilder()
.header("X-API-Key", apikey)
.build()
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(OpenChargeMapApi::class.java)
}
}
}
class OpenChargeMapApiWrapper(
apikey: String,
baseurl: String = "https://api.openchargemap.io/v3/",
context: Context? = null
) : ChargepointApi<OCMReferenceData> {
val api = OpenChargeMapApi.create(apikey, baseurl, context)
override fun getName() = "OpenChargeMap.org"
private fun formatMultipleChoice(value: MultipleChoiceFilterValue?) =
if (value == null || value.all) null else value.values.joinToString(",")
override suspend fun getChargepoints(
referenceData: ReferenceData,
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?,
): Resource<List<ChargepointListItem>> {
val referenceData = referenceData as OCMReferenceData
val minPower = filters?.getSliderValue("min_power")?.toDouble()
val minConnectors = filters?.getSliderValue("min_connectors")
val excludeFaults = filters?.getBooleanValue("exclude_faults")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val operatorsVal = filters?.getMultipleChoiceValue("operators")!!
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
// no operators chosen
return Resource.success(emptyList())
}
val operators = formatMultipleChoice(operatorsVal)
try {
val response = api.getChargepoints(
OCMBoundingBox(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude
),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
var result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getChargepointsRadius(
referenceData: ReferenceData,
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues?
): Resource<List<ChargepointListItem>> {
val referenceData = referenceData as OCMReferenceData
val minPower = filters?.getSliderValue("min_power")?.toDouble()
val minConnectors = filters?.getSliderValue("min_connectors")
val excludeFaults = filters?.getBooleanValue("exclude_faults")
val connectorsVal = filters?.getMultipleChoiceValue("connectors")
if (connectorsVal != null && connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val operatorsVal = filters?.getMultipleChoiceValue("operators")
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
// no operators chosen
return Resource.success(emptyList())
}
val operators = formatMultipleChoice(operatorsVal)
try {
val response = api.getChargepointsRadius(
location.latitude, location.longitude,
radius.toDouble(),
minPower = minPower,
plugs = connectors,
operators = operators,
statusType = if (excludeFaults == true) noFaultStatuses.joinToString(",") else null
)
if (!response.isSuccessful) {
return Resource.error(response.message(), null)
}
val result = postprocessResult(
response.body()!!,
minPower,
connectorsVal,
minConnectors,
referenceData,
zoom
)
return Resource.success(result)
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
private fun postprocessResult(
chargers: List<OCMChargepoint>,
minPower: Double?,
connectorsVal: MultipleChoiceFilterValue?,
minConnectors: Int?,
referenceData: OCMReferenceData,
zoom: Float
): List<ChargepointListItem> {
// apply filters which OCM does not support natively
var result = chargers.filter { it ->
it.connections
.filter { it.power == null || it.power >= (minPower ?: 0.0) }
.filter { if (connectorsVal != null && !connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true }
.sumOf { it.quantity ?: 1 } >= (minConnectors ?: 0)
}.map { it.convert(referenceData) }.distinct() as List<ChargepointListItem>
// apply clustering
val useClustering = zoom < 13
if (useClustering) {
val clusterDistance = getClusterDistance(zoom)
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return result
}
override suspend fun getChargepointDetail(
referenceData: ReferenceData,
id: Long
): Resource<ChargeLocation> {
val referenceData = referenceData as OCMReferenceData
try {
val response = api.getChargepointDetail(id)
if (response.isSuccessful && response.body()?.size == 1) {
return Resource.success(response.body()!![0].convert(referenceData))
} else {
return Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override suspend fun getReferenceData(): Resource<OCMReferenceData> {
try {
val response = api.getReferenceData()
if (response.isSuccessful) {
return Resource.success(response.body()!!)
} else {
return Resource.error(response.message(), null)
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
}
override fun getFilters(
referenceData: ReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val referenceData = referenceData as OCMReferenceData
val operatorsMap = referenceData.operators.map { it.id.toString() to it.title }.toMap()
val plugMap = referenceData.connectionTypes.map { it.id.toString() to it.title }.toMap()
return listOf(
// supported by OCM API
SliderFilter(
sp.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(
"1", // Type 1 (J1772)
"25", // Type 2 (Socket only)
"1036", // Type 2 (Tethered connector)
"32", // CCS (Type 1)
"33", // CCS (Type 2)
"2" // CHAdeMO
),
manyChoices = true
),
MultipleChoiceFilter(
sp.getString(R.string.filter_operators), "operators",
operatorsMap, manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults"),
// local filters
SliderFilter(
sp.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
)
}
}

View File

@@ -0,0 +1,261 @@
package net.vonforst.evmap.api.openchargemap
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.max
import net.vonforst.evmap.model.*
import java.time.ZonedDateTime
// Unknown, Currently Available, Currently In Use, Operational
val noFaultStatuses = listOf(0, 10, 20, 50)
// Temporarily Unavailable, Partly Operational, Not Operational, Planned For Future Date, Removed (Decommissioned)
val faultStatuses = listOf(30L, 75L, 100L, 150L, 200L)
val faultReportCommentType = 1000L
data class OCMBoundingBox(
val sw_lat: Double, val sw_lng: Double,
val ne_lat: Double, val ne_lng: Double
) {
override fun toString(): String {
return "($sw_lat,$sw_lng),($ne_lat,$ne_lng)"
}
}
@JsonClass(generateAdapter = true)
data class OCMChargepoint(
@Json(name = "ID") val id: Long,
@Json(name = "IsRecentlyVerified") val recentlyVerified: Boolean,
@Json(name = "DateLastVerified") val dateLastVerified: ZonedDateTime?,
@Json(name = "UsageCost") val cost: String?,
@Json(name = "AddressInfo") val addressInfo: OCMAddressInfo,
@Json(name = "Connections") val connections: List<OCMConnection>,
@Json(name = "NumberOfPoints") val numPoints: Int?,
@Json(name = "GeneralComments") val generalComments: String?,
@Json(name = "OperatorInfo") val operatorInfo: OCMOperator?,
@Json(name = "OperatorID") val operatorId: Long?,
@Json(name = "DataProvider") val dataProvider: OCMDataProvider?,
@Json(name = "MediaItems") val mediaItems: List<OCMMediaItem>?,
@Json(name = "StatusTypeID") val statusTypeId: Long?,
@Json(name = "StatusType") val statusType: OCMStatusType?,
@Json(name = "UserComments") val userComments: List<OCMUserComment>?,
@Json(name = "DateLastStatusUpdate") val lastStatusUpdateDate: ZonedDateTime?
) {
fun convert(refData: OCMReferenceData) = ChargeLocation(
id,
"openchargemap",
addressInfo.title,
Coordinate(addressInfo.latitude, addressInfo.longitude),
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
convertFaultReport(),
recentlyVerified,
null,
null,
generalComments,
null,
addressInfo.accessComments,
mediaItems?.mapNotNull { it.convert() },
null,
null,
cost?.let { Cost(descriptionShort = it) },
dataProvider?.let { "© ${it.title}" + if (it.license != null) ". ${it.license}" else "" },
ChargepriceData(
addressInfo.countryISOCode(refData),
operatorId?.toString(),
connections.map { "${it.connectionTypeId},${it.currentTypeId}" })
)
private fun convertFaultReport(): FaultReport? {
if (statusTypeId in faultStatuses || connections.any { it.statusTypeId in faultStatuses }) {
if (userComments != null) {
val comment = userComments.filter { it.commentTypeId == faultReportCommentType }
.maxByOrNull { it.dateCreated }
if (comment != null) {
return FaultReport(comment.dateCreated.toInstant(), comment.comment ?: "")
}
}
if (statusType != null && statusType.id in faultStatuses) {
return FaultReport(lastStatusUpdateDate?.toInstant(), statusType.title)
} else if (connections.any { it.statusType != null && it.statusTypeId in faultStatuses }) {
return FaultReport(
lastStatusUpdateDate?.toInstant(),
connections.first { it.statusType != null && it.statusTypeId in faultStatuses }.statusType!!.title
)
}
return FaultReport(null, null)
} else {
return null
}
}
}
@JsonClass(generateAdapter = true)
data class OCMAddressInfo(
@Json(name = "Title") val title: String,
@Json(name = "AddressLine1") val addressLine1: String?,
@Json(name = "AddressLine2") val addressLine2: String?,
@Json(name = "Town") val town: String?,
@Json(name = "StateOrProvince") val stateOrProvince: String?,
@Json(name = "Postcode") val postcode: String?,
@Json(name = "CountryID") val countryId: Long,
@Json(name = "Latitude") val latitude: Double,
@Json(name = "Longitude") val longitude: Double,
@Json(name = "ContactTelephone1") val contactTelephone1: String?,
@Json(name = "ContactTelephone2") val contactTelephone2: String?,
@Json(name = "ContactEmail") val contactEmail: String?,
@Json(name = "AccessComments") val accessComments: String?,
@Json(name = "RelatedURL") val relatedUrl: String?
) {
fun toAddress(refData: OCMReferenceData) = Address(
town,
refData.countries.find { it.id == countryId }?.title,
postcode,
listOfNotNull(addressLine1, addressLine2).joinToString(", ")
)
fun countryISOCode(refData: OCMReferenceData) =
refData.countries.find { it.id == countryId }?.isoCode
}
@JsonClass(generateAdapter = true)
data class OCMConnection(
@Json(name = "ConnectionTypeID") val connectionTypeId: Long,
@Json(name = "CurrentTypeID") val currentTypeId: Long?,
@Json(name = "Amps") val amps: Int?,
@Json(name = "Voltage") val voltage: Int?,
@Json(name = "PowerKW") val power: Double?,
@Json(name = "Quantity") val quantity: Int?,
@Json(name = "Comments") val comments: String?,
@Json(name = "StatusTypeID") val statusTypeId: Long?,
@Json(name = "StatusType") val statusType: OCMStatusType?
) {
fun convert(refData: OCMReferenceData) = Chargepoint(
convertConnectionTypeFromOCM(connectionTypeId, refData),
power ?: 0.0,
quantity ?: 1
)
companion object {
fun convertConnectionTypeFromOCM(id: Long, refData: OCMReferenceData): String {
val title = refData.connectionTypes.find { it.id == id }?.title
return when (id) {
32L -> Chargepoint.CCS_TYPE_1
33L -> Chargepoint.CCS_TYPE_2
2L -> Chargepoint.CHADEMO
16L -> Chargepoint.CEE_BLAU
17L -> Chargepoint.CEE_ROT
28L -> Chargepoint.SCHUKO
8L -> Chargepoint.TESLA_ROADSTER_HPC
27L -> Chargepoint.SUPERCHARGER
25L -> Chargepoint.TYPE_2_SOCKET
1036L -> Chargepoint.TYPE_2_PLUG
1L -> Chargepoint.TYPE_1
36L -> Chargepoint.TYPE_3
26L -> Chargepoint.TYPE_3
else -> title ?: ""
}
}
}
}
@JsonClass(generateAdapter = true)
data class OCMReferenceData(
@Json(name = "ConnectionTypes") val connectionTypes: List<OCMConnectionType>,
@Json(name = "Countries") val countries: List<OCMCountry>,
@Json(name = "Operators") val operators: List<OCMOperator>
) : ReferenceData()
@JsonClass(generateAdapter = true)
@Entity
data class OCMConnectionType(
@Json(name = "ID") @PrimaryKey val id: Long,
@Json(name = "Title") val title: String,
@Json(name = "FormalName") val formalName: String?,
@Json(name = "IsDiscontinued") val discontinued: Boolean?,
@Json(name = "IsObsolete") val obsolete: Boolean?
)
@JsonClass(generateAdapter = true)
@Entity
data class OCMCountry(
@Json(name = "ID") @PrimaryKey val id: Long,
@Json(name = "ISOCode") val isoCode: String,
@Json(name = "ContinentCode") val continentCode: String?,
@Json(name = "Title") val title: String
)
@JsonClass(generateAdapter = true)
data class OCMDataProvider(
@Json(name = "ID") val id: Long,
@Json(name = "WebsiteURL") val websiteUrl: String?,
@Json(name = "Title") val title: String,
@Json(name = "License") val license: String?
)
@JsonClass(generateAdapter = true)
@Entity
data class OCMOperator(
@Json(name = "ID") @PrimaryKey val id: Long,
@Json(name = "WebsiteURL") val websiteUrl: String?,
@Json(name = "Title") val title: String,
@Json(name = "ContactEmail") val contactEmail: String?,
@Json(name = "PhonePrimaryContact") val contactTelephone1: String?,
@Json(name = "PhoneSecondaryContact") val contactTelephone2: String?,
)
@JsonClass(generateAdapter = true)
data class OCMMediaItem(
@Json(name = "ID") val id: Long,
@Json(name = "ItemURL") val url: String,
@Json(name = "ItemThumbnailURL") val thumbUrl: String,
@Json(name = "IsVideo") val isVideo: Boolean,
@Json(name = "IsExternalResource") val isExternalResource: Boolean,
@Json(name = "Comment") val comment: String?
) {
fun convert(): ChargerPhoto? {
if (isVideo or isExternalResource) return null
return OCMChargerPhotoAdapter(id.toString(), url, thumbUrl)
}
}
@JsonClass(generateAdapter = true)
data class OCMUserComment(
@Json(name = "ID") val id: Long,
@Json(name = "CommentTypeID") val commentTypeId: Long,
@Json(name = "Comment") val comment: String?,
@Json(name = "UserName") val userName: String,
@Json(name = "DateCreated") val dateCreated: ZonedDateTime
)
@JsonClass(generateAdapter = true)
data class OCMStatusType(
@Json(name = "ID") val id: Long,
@Json(name = "Title") val title: String
)
@Parcelize
@JsonClass(generateAdapter = true)
class OCMChargerPhotoAdapter(
override val id: String,
val largeUrl: String,
val thumbUrl: String
) : ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
val maxSize = size ?: max(height, width)
val mediumUrl = thumbUrl.replace(".thmb.", ".medi.")
return when (maxSize) {
0 -> mediumUrl
in 1..100 -> thumbUrl
in 101..400 -> mediumUrl
else -> largeUrl
}
}
}

View File

@@ -0,0 +1,189 @@
package net.vonforst.evmap.autocomplete
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.parcelize.Parcelize
interface AutocompleteProvider {
val id: String
fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace>
suspend fun getDetails(id: String): PlaceWithBounds
@StringRes
fun getAttributionString(): Int
@DrawableRes
fun getAttributionImage(dark: Boolean): Int
}
data class AutocompletePlace(
val primaryText: CharSequence,
val secondaryText: CharSequence,
val id: String,
val distanceMeters: Double?,
val types: List<AutocompletePlaceType>
)
class ApiUnavailableException : Exception()
enum class AutocompletePlaceType {
// based on Google Places Place.Type enum
OTHER,
ACCOUNTING,
ADMINISTRATIVE_AREA_LEVEL_1,
ADMINISTRATIVE_AREA_LEVEL_2,
ADMINISTRATIVE_AREA_LEVEL_3,
ADMINISTRATIVE_AREA_LEVEL_4,
ADMINISTRATIVE_AREA_LEVEL_5,
AIRPORT,
AMUSEMENT_PARK,
AQUARIUM,
ARCHIPELAGO,
ART_GALLERY,
ATM,
BAKERY,
BANK,
BAR,
BEAUTY_SALON,
BICYCLE_STORE,
BOOK_STORE,
BOWLING_ALLEY,
BUS_STATION,
CAFE,
CAMPGROUND,
CAR_DEALER,
CAR_RENTAL,
CAR_REPAIR,
CAR_WASH,
CASINO,
CEMETERY,
CHURCH,
CITY_HALL,
CLOTHING_STORE,
COLLOQUIAL_AREA,
CONTINENT,
CONVENIENCE_STORE,
COUNTRY,
COURTHOUSE,
DENTIST,
DEPARTMENT_STORE,
DOCTOR,
DRUGSTORE,
ELECTRICIAN,
ELECTRONICS_STORE,
EMBASSY,
ESTABLISHMENT,
FINANCE,
FIRE_STATION,
FLOOR,
FLORIST,
FOOD,
FUNERAL_HOME,
FURNITURE_STORE,
GAS_STATION,
GENERAL_CONTRACTOR,
GEOCODE,
GROCERY_OR_SUPERMARKET,
GYM,
HAIR_CARE,
HARDWARE_STORE,
HEALTH,
HINDU_TEMPLE,
HOME_GOODS_STORE,
HOSPITAL,
INSURANCE_AGENCY,
INTERSECTION,
JEWELRY_STORE,
LAUNDRY,
LAWYER,
LIBRARY,
LIGHT_RAIL_STATION,
LIQUOR_STORE,
LOCAL_GOVERNMENT_OFFICE,
LOCALITY,
LOCKSMITH,
LODGING,
MEAL_DELIVERY,
MEAL_TAKEAWAY,
MOSQUE,
MOVIE_RENTAL,
MOVIE_THEATER,
MOVING_COMPANY,
MUSEUM,
NATURAL_FEATURE,
NEIGHBORHOOD,
NIGHT_CLUB,
PAINTER,
PARK,
PARKING,
PET_STORE,
PHARMACY,
PHYSIOTHERAPIST,
PLACE_OF_WORSHIP,
PLUMBER,
PLUS_CODE,
POINT_OF_INTEREST,
POLICE,
POLITICAL,
POST_BOX,
POST_OFFICE,
POSTAL_CODE_PREFIX,
POSTAL_CODE_SUFFIX,
POSTAL_CODE,
POSTAL_TOWN,
PREMISE,
PRIMARY_SCHOOL,
REAL_ESTATE_AGENCY,
RESTAURANT,
ROOFING_CONTRACTOR,
ROOM,
ROUTE,
RV_PARK,
SCHOOL,
SECONDARY_SCHOOL,
SHOE_STORE,
SHOPPING_MALL,
SPA,
STADIUM,
STORAGE,
STORE,
STREET_ADDRESS,
STREET_NUMBER,
SUBLOCALITY_LEVEL_1,
SUBLOCALITY_LEVEL_2,
SUBLOCALITY_LEVEL_3,
SUBLOCALITY_LEVEL_4,
SUBLOCALITY_LEVEL_5,
SUBLOCALITY,
SUBPREMISE,
SUBWAY_STATION,
SUPERMARKET,
SYNAGOGUE,
TAXI_STAND,
TOURIST_ATTRACTION,
TOWN_SQUARE,
TRAIN_STATION,
TRANSIT_STATION,
TRAVEL_AGENCY,
UNIVERSITY,
VETERINARY_CARE,
ZOO,
RECENT;
companion object {
fun valueOfOrNull(value: String): AutocompletePlaceType? {
try {
return valueOf(value)
} catch (e: IllegalArgumentException) {
return null
}
}
}
}
@Parcelize
data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?) : Parcelable

View File

@@ -0,0 +1,127 @@
package net.vonforst.evmap.autocomplete
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.CharacterStyle
import android.text.style.StyleSpan
import androidx.core.os.ConfigurationCompat
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.car2go.maps.util.SphericalUtil
import com.mapbox.api.geocoding.v5.GeocodingCriteria
import com.mapbox.api.geocoding.v5.MapboxGeocoding
import com.mapbox.api.geocoding.v5.models.CarmenFeature
import com.mapbox.geojson.BoundingBox
import com.mapbox.geojson.Point
import net.vonforst.evmap.R
import java.io.IOException
class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider {
private val bold: CharacterStyle = StyleSpan(Typeface.BOLD)
private val results = HashMap<String, CarmenFeature>()
override val id = "mapbox"
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
val result = MapboxGeocoding.builder().apply {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
languages(ConfigurationCompat.getLocales(context.resources.configuration)[0].language)
accessToken(context.getString(R.string.mapbox_key))
autocomplete(true)
this.query(query)
}.build().executeCall()
if (!result.isSuccessful) {
throw IOException(result.message())
}
return result.body()!!.features().map { feature ->
results[feature.id()!!] = feature
var secondaryText = (feature.matchingPlaceName() ?: feature.placeName())!!
val matchingText = (feature.matchingText() ?: feature.text())!!
val primaryText =
if (feature.address() != null && secondaryText.startsWith(feature.address() + " " + matchingText)) {
// countries where house number comes in front of road ("10 Downing Street")
feature.address() + " " + matchingText
} else {
// countries where house number comes after road ("Willy-Brandt-Str. 1")
matchingText + (feature.address()?.let { " $it" } ?: "")
}
secondaryText = secondaryText.replace("$primaryText, ", "")
AutocompletePlace(
highlightMatch(primaryText, query),
secondaryText,
feature.id()!!,
location?.let { location ->
SphericalUtil.computeDistanceBetween(
feature.center()!!.toLatLng(), location
)
},
getPlaceTypes(feature)
)
}
}
private fun getPlaceTypes(feature: CarmenFeature): List<AutocompletePlaceType> {
val types = feature.placeType()?.mapNotNull {
when (it) {
GeocodingCriteria.TYPE_COUNTRY -> AutocompletePlaceType.COUNTRY
GeocodingCriteria.TYPE_REGION -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_1
GeocodingCriteria.TYPE_POSTCODE -> AutocompletePlaceType.POSTAL_CODE
GeocodingCriteria.TYPE_DISTRICT -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_2
GeocodingCriteria.TYPE_PLACE -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_3
GeocodingCriteria.TYPE_LOCALITY -> AutocompletePlaceType.LOCALITY
GeocodingCriteria.TYPE_NEIGHBORHOOD -> AutocompletePlaceType.NEIGHBORHOOD
GeocodingCriteria.TYPE_ADDRESS -> AutocompletePlaceType.STREET_ADDRESS
GeocodingCriteria.TYPE_POI -> AutocompletePlaceType.POINT_OF_INTEREST
GeocodingCriteria.TYPE_POI_LANDMARK -> AutocompletePlaceType.POINT_OF_INTEREST
else -> null
}
} ?: emptyList()
val categories = feature.properties()?.get("category")?.asString?.split(", ")?.mapNotNull {
// Place categories are defined at https://docs.mapbox.com/api/search/geocoding/#point-of-interest-category-coverage
// We try to find a matching entry in the enum.
// TODO: map categories that are not named the same
AutocompletePlaceType.valueOfOrNull(it.uppercase().replace(" ", "_"))
} ?: emptyList()
return types + categories
}
private fun highlightMatch(text: String, query: String): CharSequence {
val result = SpannableString(text)
val startPos = text.lowercase().indexOf(query.lowercase())
if (startPos > -1) {
val endPos = startPos + query.length
result.setSpan(bold, startPos, endPos, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return result
}
override suspend fun getDetails(id: String): PlaceWithBounds {
val place = results[id]!!
results.clear()
return PlaceWithBounds(
place.center()!!.toLatLng(),
place.geometry()?.bbox()?.toLatLngBounds()
)
}
override fun getAttributionString(): Int = R.string.powered_by_mapbox
override fun getAttributionImage(dark: Boolean): Int =
if (dark) R.drawable.mapbox_logo_icon else R.drawable.mapbox_logo
}
private fun BoundingBox.toLatLngBounds(): LatLngBounds {
return LatLngBounds(
southwest().toLatLng(),
northeast().toLatLng()
)
}
private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude())

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