Compare commits

...

101 Commits
0.7.2 ... 0.9.0

Author SHA1 Message Date
johan12345
c2292ad7fa Release 0.9.0 2021-08-09 19:01:35 +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
144 changed files with 10362 additions and 2671 deletions

View File

@@ -1,11 +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

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

@@ -3,7 +3,7 @@ EVMap [![Build Status](https://travis-ci.org/johan12345/EVMap.svg?branch=master)
<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,11 +14,12 @@ 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
- Charging price comparison, powered by [Chargeprice.app](https://chargeprice.app)
- Integrated price comparison using Chargeprice.app [Chargeprice.app](https://chargeprice.app) (only in Europe)
- Android Auto integration
- No ads, fully open source
- Compatible with Android 5.0 and above
@@ -36,9 +37,10 @@ The App is developed using Android Studio.
For testing the app, you need to obtain free API Keys for the
[GoingElectric API](https://www.goingelectric.de/stromtankstellen/api/),
the [Chargeprice API](https://github.com/chargeprice/chargeprice-api-docs)
the [Chargeprice API](https://github.com/chargeprice/chargeprice-api-docs),
the [OpenChargeMap API](https://openchargemap.org/site/profile/appedit),
as well as for [Google APIs](https://console.developers.google.com/)
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These APIs need to be put into the
("Maps SDK for Android" and "Places API" need to be activated) and/or [Mapbox](https://www.mapbox.com/). These API keys need to be put into the
app in the form of a resource file called `apikeys.xml` under `app/src/main/res/values`, with the
following content:
@@ -56,5 +58,8 @@ following content:
<string name="chargeprice_key" translatable="false">
insert your Chargeprice key here
</string>
<string name="openchargemap_key" translatable="false">
insert your OpenChargeMap key here
</string>
</resources>
```

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

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 30
versionCode 46
versionName "0.7.2"
versionCode 53
versionName "0.9.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -77,6 +77,13 @@ 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
@@ -98,40 +105,41 @@ android {
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.5.0-rc01'
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.core:core-ktx:1.5.0'
implementation "androidx.activity:activity-ktx:1.2.3"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation 'androidx.cardview:cardview:1.0.0'
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 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.browser:browser:1.3.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 '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.0.0-rc01'
googleImplementation 'androidx.car.app:app:1.0.0'
// AnyMaps
def anyMapsVersion = '1f050d860f'
def anyMapsVersion = '95ddd6c083'
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
implementation "com.github.johan12345.AnyMaps:anymaps-mapbox:$anyMapsVersion"
@@ -139,7 +147,7 @@ dependencies {
// 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.android.volley:volley:1.2.0'
googleImplementation 'com.google.android.gms:play-services-base:17.5.0'
googleImplementation 'com.google.android.gms:play-services-basement:17.5.0'
googleImplementation 'com.google.android.gms:play-services-gcm:17.0.0'
@@ -151,29 +159,29 @@ dependencies {
googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Mapbox places (autocomplete)
implementation('com.mapbox.mapboxsdk:mapbox-android-plugin-places-v9:0.12.0') {
// forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011
implementation('com.github.johan12345.mapbox-plugins-android:mapbox-android-plugin-places-v9:922bf877f6') {
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-accounts'
exclude group: 'com.mapbox.mapboxsdk', module: 'mapbox-android-telemetry'
}
// 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.3.1"
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"
@@ -187,9 +195,9 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.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) {

View File

@@ -4,57 +4,24 @@ import android.Manifest
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.location.Location
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
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.Session
import androidx.car.app.model.*
import androidx.car.app.model.Distance.UNIT_KILOMETERS
import androidx.car.app.validation.HostValidator
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.*
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.*
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
import java.time.Duration
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
interface LocationAwareScreen {
fun updateLocation(location: Location)
}
@androidx.car.app.annotations.ExperimentalCarApi
class CarAppService : androidx.car.app.CarAppService() {
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
@@ -71,6 +38,7 @@ class CarAppService : androidx.car.app.CarAppService() {
}
}
@androidx.car.app.annotations.ExperimentalCarApi
class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
var mapScreen: LocationAwareScreen? = null
set(value) {
@@ -153,512 +121,3 @@ class EVMapSession(val cas: CarAppService) : Session(), LifecycleObserver {
}
}
/**
* 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 {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
invalidate()
}
}
/**
* Screen to grant location permission
*/
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.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 {
val intent = Intent(carContext, PermissionActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(
PermissionActivity.EXTRA_RESULT_RECEIVER,
object : ResultReceiver(null) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
session.bindLocationService()
screenManager.push(
WelcomeScreen(
carContext,
session
)
)
}
}
})
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
}
/**
* 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
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private val api by lazy {
GoingElectricApi.create(ctx.getString(R.string.goingelectric_key), context = ctx)
}
private val searchRadius = 5 // kilometers
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = 6
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()
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger))
}
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)
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation): Row {
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
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 {
setTitle(charger.name)
val text = SpannableStringBuilder()
// distance
location?.let {
val distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
) / 1000
text.append(
"distance",
DistanceSpan.create(Distance.create(distance, UNIT_KILOMETERS)),
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) {
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
invalidate()
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastUpdateLocation = location
// update displayed chargers
loadChargers(location)
}
}
private val db = AppDatabase.getInstance(carContext)
private fun loadChargers(location: Location) {
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(
location.latitude,
location.longitude,
searchRadius,
zoom = 16f
)
chargers =
response.body()?.chargelocations?.filterIsInstance(ChargeLocation::class.java)
chargers?.let {
if (it.size < 6) {
// try again with larger radius
val response = api.getChargepointsRadius(
location.latitude,
location.longitude,
searchRadius * 5,
zoom = 16f
)
chargers =
response.body()?.chargelocations?.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
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}
class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) : Screen(ctx) {
var charger: ChargeLocation? = null
var photo: Bitmap? = null
private var availability: ChargeLocationStatus? = null
val apikey = ctx.getString(R.string.goingelectric_key)
private val api by lazy {
GoingElectricApi.create(apikey, context = ctx)
}
private val iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
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,
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)
}
}
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())
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
}.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() {
lifecycleScope.launch {
try {
val response = api.getChargepointDetail(chargerSparse.id)
charger = response.body()?.chargelocations?.get(0) as ChargeLocation
val photo = charger?.photos?.firstOrNull()
photo?.let {
val size = (carContext.resources.displayMetrics.density * 64).roundToInt()
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=${photo.id}&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()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}
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
}
}

View File

@@ -0,0 +1,213 @@
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.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 iconGen = ChargerIconGenerator(carContext, null, oversize = 1.4f, height = 64)
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())
addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.open_in_app))
.setOnClickListener(ParkedOnlyOnClickListener.create {
val intent = Intent(carContext, MapsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_CHARGER_ID, charger.id)
.putExtra(EXTRA_LAT, charger.coordinates.lat)
.putExtra(EXTRA_LON, charger.coordinates.lng)
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
} ?: setLoading(true)
}.build()
).apply {
setTitle(chargerSparse.name)
setHeaderAction(Action.BACK)
}.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,90 @@
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_DISABLED
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 = CarIcon.Builder(
IconCompat.createWithBitmap(
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888
)
)
).build()
}
init {
filterProfiles.observe(this) {
invalidate()
}
}
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
filterProfiles.value?.let {
setSingleList(buildFilterProfilesList(it.take(maxRows), prefs.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 {
setTitle(it.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,313 @@
package net.vonforst.evmap.auto
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.model.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.MutableLiveData
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.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.ZonedDateTime
import kotlin.collections.set
import kotlin.math.roundToInt
/**
* Main map screen showing either nearby chargers or favorites
*/
@androidx.car.app.annotations.ExperimentalCarApi
class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boolean = false) :
Screen(ctx), LocationAwareScreen {
private var updateCoroutine: Job? = null
private var numUpdates = 0
private val maxNumUpdates = 3
private var location: Location? = null
private var lastUpdateLocation: Location? = null
private var chargers: List<ChargeLocation>? = null
private var prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
createApi(prefs.dataSource, ctx)
}
private val searchRadius = 5 // kilometers
private val updateThreshold = 2000 // meters
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus>> =
HashMap()
private val maxRows = 6
private val referenceData = api.getReferenceData(lifecycleScope, carContext)
private val filterStatus = MutableLiveData<Long>().apply {
value = prefs.filterStatus.takeUnless { it == FILTERS_CUSTOM } ?: 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)
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
}
session.mapScreen = null
}
.build())
.build())
}
build()
}.build()
}
private fun formatCharger(charger: ChargeLocation, showCity: Boolean): Row {
val color = ContextCompat.getColor(carContext, getMarkerTint(charger))
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 distance = distanceBetween(
it.latitude, it.longitude,
charger.coordinates.lat, charger.coordinates.lng
) / 1000
text.append(
"distance",
DistanceSpan.create(Distance.create(distance, Distance.UNIT_KILOMETERS)),
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) {
this.location = location
if (updateCoroutine != null) {
// don't update while still loading last update
return
}
invalidate()
if (lastUpdateLocation == null ||
location.distanceTo(lastUpdateLocation) > updateThreshold
) {
lastUpdateLocation = 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 < 6) {
// 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
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
.show()
}
}
}
}
}

View File

@@ -0,0 +1,65 @@
package net.vonforst.evmap.auto
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.*
import net.vonforst.evmap.R
/**
* Screen to grant location permission
*/
@androidx.car.app.annotations.ExperimentalCarApi
class PermissionScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
override fun onGetTemplate(): Template {
return MessageTemplate.Builder(carContext.getString(R.string.auto_location_permission_needed))
.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 {
val intent = Intent(carContext, PermissionActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(
PermissionActivity.EXTRA_RESULT_RECEIVER,
object : ResultReceiver(null) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultData!!.getBoolean(PermissionActivity.RESULT_GRANTED)) {
session.bindLocationService()
screenManager.push(
WelcomeScreen(
carContext,
session
)
)
}
}
})
carContext.startActivity(intent)
CarToast.makeText(
carContext,
R.string.opened_on_phone,
CarToast.LENGTH_LONG
).show()
})
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.cancel))
.setOnClickListener {
carContext.finishCarApp()
}
.build(),
)
.build()
}
}

View File

@@ -0,0 +1,20 @@
package net.vonforst.evmap.auto
import androidx.car.app.model.CarColor
import net.vonforst.evmap.api.availability.ChargepointStatus
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
}
}

View File

@@ -0,0 +1,70 @@
package net.vonforst.evmap.auto
import android.location.Location
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
*/
@androidx.car.app.annotations.ExperimentalCarApi
class WelcomeScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx), LocationAwareScreen {
private var location: Location? = null
override fun onGetTemplate(): Template {
session.mapScreen = this
return PlaceListMapTemplate.Builder().apply {
setTitle(carContext.getString(R.string.app_name))
location?.let {
setAnchor(Place.Builder(CarLocation.create(it)).build())
}
setItemList(ItemList.Builder().apply {
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_chargers_closeby))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_address
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = false))
}
.build())
addItem(
Row.Builder()
.setTitle(carContext.getString(R.string.auto_favorites))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_fav
)
)
.setTint(CarColor.DEFAULT).build()
)
.setBrowsable(true)
.setOnClickListener {
screenManager.push(MapScreen(carContext, session, favorites = true))
}
.build())
}.build())
setCurrentLocationEnabled(true)
setHeaderAction(Action.APP_ICON)
build()
}.build()
}
override fun updateLocation(location: Location) {
this.location = location
invalidate()
}
}

View File

@@ -1,33 +0,0 @@
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
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))
}

View File

@@ -54,6 +54,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()
})

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

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

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

View File

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

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -18,10 +19,11 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
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.MapFragment
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
@@ -56,6 +58,7 @@ class MapsActivity : AppCompatActivity() {
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,
@@ -67,7 +70,7 @@ class MapsActivity : AppCompatActivity() {
)
val navView = findViewById<NavigationView>(R.id.nav_view)
navView.setupWithNavController(navController)
val header = navView.getHeaderView(0)
ViewCompat.setOnApplyWindowInsetsListener(header) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
@@ -78,29 +81,37 @@ class MapsActivity : AppCompatActivity() {
checkPlayServices(this)
if (intent?.scheme == "geo") {
val pos = intent.data?.schemeSpecificPart?.split("?")?.get(0)
val query = intent.data?.query?.split("=")?.get(1)
val coords = pos?.split(",")?.map { it.toDoubleOrNull() }
if (coords != null && coords.size == 2) {
if (!prefs.welcomeDialogShown || !prefs.dataSourceSet) {
navGraph.startDestination = R.id.onboarding
navController.graph = navGraph
return
} else {
navGraph.startDestination = 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]
if (lat != null && lon != null && lat != 0.0 && lon != 0.0) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocation(lat, lon))
.createPendingIntent()
deepLink.send()
} else if (query != null && query.isNotEmpty()) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocationByName(query))
.createPendingIntent()
deepLink.send()
}
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocation(lat, lon))
.createPendingIntent()
deepLink.send()
} else if (query != null && query.isNotEmpty()) {
val deepLink = navController.createDeepLink()
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.map)
.setArguments(MapFragment.showLocationByName(query))
.createPendingIntent()
deepLink.send()
}
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
@@ -164,7 +175,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) {

View File

@@ -4,6 +4,11 @@ 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
fun Bundle.optDouble(name: String): Double? {
if (!this.containsKey(name)) return null
@@ -51,4 +56,45 @@ 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
}
}
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)
}
}
}
}

View File

@@ -9,20 +9,22 @@ 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.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.api.goingelectric.Chargepoint
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
import net.vonforst.evmap.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean;
override fun equals(other: Any?): Boolean
}
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
@@ -86,18 +88,6 @@ class ConnectorAdapter : DataBindingAdapter<ConnectorAdapter.ChargepointWithAvai
override fun getItemViewType(position: Int): Int = R.layout.item_connector
}
class FavoritesAdapter(val vm: FavoritesViewModel) :
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
}
class ChargepriceAdapter() :
DataBindingAdapter<ChargePrice>() {
@@ -178,8 +168,10 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
}
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
if (checked) {
checkedItem = position
notifyDataSetChanged()
checkedItem = holder.bindingAdapterPosition
root.post {
notifyDataSetChanged()
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
@@ -188,7 +180,7 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
fun setCheckedItem(item: Chargepoint?) {
checkedItem = item?.let { currentList.indexOf(item) } ?: null
checkedItem = item?.let { currentList.indexOf(item) }
}
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
@@ -197,4 +189,38 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
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,
@@ -119,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,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,17 +1,17 @@
package net.vonforst.evmap.api
import android.content.Context
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.Chargepoint
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 {
@@ -45,9 +45,13 @@ suspend fun Call.await(): Response {
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2 to R.string.plug_type_2,
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 to R.string.plug_ccs,
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,
@@ -56,22 +60,52 @@ private val plugNames = mapOf(
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
)
fun nameForPlugType(ctx: Context, type: String): String =
fun nameForPlugType(ctx: StringProvider, type: String): String =
plugNames[type]?.let {
ctx.getString(it)
} ?: type
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 -> R.drawable.ic_connector_ccs
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 -> R.drawable.ic_connector_typ2
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,12 +6,11 @@ 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.viewmodel.FilterValues
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 net.vonforst.evmap.viewmodel.getMultipleChoiceValue
import net.vonforst.evmap.viewmodel.getSliderValue
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -68,8 +67,9 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
): Map<Chargepoint, Set<Long>> {
// iterate over each connector type
val types = connectors.map { it.value.second }.distinct().toSet()
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
val geTypes = chargepoints.map { it.type }.distinct().toSet()
if (types != geTypes) throw AvailabilityDetectorException("chargepoints do not match")
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 }
@@ -77,13 +77,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")
@@ -117,14 +118,12 @@ data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
) {
fun applyFilters(filters: FilterValues?): ChargeLocationStatus {
if (filters == null) return this
val connectorsVal = filters.getMultipleChoiceValue("connectors")
val minPower = filters.getSliderValue("min_power")
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {
val statusFiltered = status.filterKeys {
(connectorsVal.all || it.type in connectorsVal.values) && it.power > minPower
(connectors == null || connectors.map {
equivalentPlugTypes(it)
}.any { equivalent -> it.type in equivalent })
&& (minPower == null || it.power > minPower)
}
return this.copy(status = statusFiltered)
}

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,8 +1,8 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonClass
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
@@ -141,11 +141,11 @@ 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"

View File

@@ -9,7 +9,8 @@ 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.goingelectric.ChargeLocation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor
@@ -33,19 +34,23 @@ data class ChargepriceStation(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepoint>
) {
companion object {
fun fromGoingelectric(
geCharger: ChargeLocation,
compatibleConnectors: List<String>
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(
geCharger.coordinates.lng,
geCharger.coordinates.lat,
geCharger.address.country,
geCharger.network,
geCharger.chargepoints.filter {
it.type in compatibleConnectors
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.power, it.type)
ChargepriceChargepoint(it.first.power, it.second)
}
)
}
@@ -112,7 +117,7 @@ class ChargepriceTariff() : Resource() {
}
@JsonApi(type = "car")
class ChargepriceCar : Resource() {
class ChargepriceCar : Resource(), Equatable {
lateinit var name: String
lateinit var brand: String

View File

@@ -13,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
)
@@ -26,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()
@@ -61,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.
}
}
@@ -94,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")
}
@@ -126,20 +126,20 @@ 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)
if (match != null) {
return Hours(
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 Hours(
return GEHours(
LocalTime.MIN, LocalTime.MIN
)
}
@@ -147,7 +147,7 @@ internal class HoursAdapter {
}
@ToJson
fun toJson(value: Hours): String {
fun toJson(value: GEHours): String {
if (value.start == null || value.end == null) {
return "closed"
} else {

View File

@@ -1,9 +1,21 @@
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.Response
@@ -11,6 +23,7 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
interface GoingElectricApi {
@GET("chargepoints/")
@@ -31,7 +44,7 @@ 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/")
suspend fun getChargepointsRadius(
@@ -52,24 +65,24 @@ 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/")
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<ChargepointList>
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
val moshi = Moshi.Builder()
private val moshi = Moshi.Builder()
.add(ChargepointListItemJsonAdapterFactory())
.add(JsonObjectOrFalseAdapter.Factory())
.add(HoursAdapter())
@@ -106,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, 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 = setOf(
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.CCS_UNKNOWN,
Chargepoint.CHADEMO
),
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,58 +1,47 @@
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:
@@ -60,260 +49,194 @@ data class ChargeLocation(
@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
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
/**
* 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, emoji: Boolean = false): CharSequence {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
}
fun convert() = Cost(freecharging, freeparking, 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() = Hours(start, end)
}
@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

@@ -3,6 +3,7 @@ package net.vonforst.evmap.autocomplete
import android.content.Context
import android.content.Intent
import android.view.inputmethod.InputMethodManager
import androidx.core.os.ConfigurationCompat
import androidx.fragment.app.Fragment
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
@@ -15,9 +16,13 @@ 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)
fun launchAutocomplete(fragment: Fragment, location: LatLng?) {
val placeOptions = PlaceOptions.builder().apply {
location?.let {
proximity(Point.fromLngLat(location.longitude, location.latitude))
}
language(ConfigurationCompat.getLocales(fragment.resources.configuration)[0].language)
}.build(PlaceOptions.MODE_CARDS)
val intent = PlaceAutocomplete.IntentBuilder()
.accessToken(fragment.getString(R.string.mapbox_key))

View File

@@ -19,11 +19,17 @@ import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ReferenceData
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.viewModelFactory
@@ -51,7 +57,7 @@ class ChargepriceFragment : DialogFragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice, container, false
@@ -87,13 +93,26 @@ class ChargepriceFragment : DialogFragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
val jsonAdapter = GoingElectricApi.moshi.adapter(ChargeLocation::class.java)
val charger = jsonAdapter.fromJson(requireArguments().getString(ARG_CHARGER)!!)!!
val charger = requireArguments().getParcelable<ChargeLocation>(ARG_CHARGER)!!
val dataSource = requireArguments().getString(ARG_DATASOURCE)!!
vm.charger.value = charger
vm.dataSource.value = dataSource
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged.get(0)
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
binding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar> = Observer {
vehicleAdapter.setCheckedItem(it)
}
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
vehicleAdapter.onCheckedItemChangedListener = {
vm.vehicle.removeObserver(vehicleObserver)
vm.vehicle.value = it
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
}
val chargepriceAdapter = ChargepriceAdapter().apply {
onClickListener = {
(requireActivity() as MapsActivity).openUrl(it.url)
@@ -131,8 +150,9 @@ class ChargepriceFragment : DialogFragment() {
vm.chargepoint.observe(viewLifecycleOwner, observer)
}
vm.vehicleCompatibleConnectors.observe(viewLifecycleOwner) {
connectorsAdapter.enabledConnectors = it
vm.vehicleCompatibleConnectors.observe(viewLifecycleOwner) { plugs ->
connectorsAdapter.enabledConnectors =
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
}
binding.connectorsList.apply {
@@ -198,13 +218,25 @@ class ChargepriceFragment : DialogFragment() {
}
companion object {
val ARG_CHARGER = "charger"
const val ARG_CHARGER = "charger"
const val ARG_DATASOURCE = "datasource"
fun showCharger(charger: ChargeLocation): Bundle {
fun showCharger(
charger: ChargeLocation,
dataSource: Class<ChargepointApi<ReferenceData>>
): Bundle {
return Bundle().apply {
putString(
putParcelable(
ARG_CHARGER,
GoingElectricApi.moshi.adapter(ChargeLocation::class.java).toJson(charger)
charger
)
putString(
ARG_DATASOURCE,
when (dataSource) {
GoingElectricApiWrapper::class.java -> "going_electric"
OpenChargeMapApiWrapper::class.java -> "open_charge_map"
else -> throw IllegalArgumentException("unsupported data source")
}
)
}
}

View File

@@ -0,0 +1,84 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import net.vonforst.evmap.databinding.DialogDataSourceSelectBinding
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.PreferenceDataSource
import java.util.*
class DataSourceSelectDialog : AppCompatDialogFragment() {
private lateinit var binding: DialogDataSourceSelectBinding
var okListener: ((String) -> Unit)? = null
companion object {
fun getInstance(
cancelEnabled: Boolean
): DataSourceSelectDialog {
val dialog = DataSourceSelectDialog()
dialog.arguments = args(cancelEnabled)
return dialog
}
fun args(cancelEnabled: Boolean) = Bundle().apply {
putBoolean("cancel_enabled", cancelEnabled)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogDataSourceSelectBinding.inflate(inflater, container, false)
prefs = PreferenceDataSource(requireContext())
return binding.root
}
override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
private lateinit var prefs: PreferenceDataSource
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
binding.btnCancel.visibility =
if (args.getBoolean("cancel_enabled")) View.VISIBLE else View.GONE
if (prefs.dataSourceSet) {
when (prefs.dataSource) {
"goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true
"openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true
}
}
binding.btnCancel.setOnClickListener {
dismiss()
}
binding.btnOK.setOnClickListener {
val result = if (binding.rgDataSource.rbGoingElectric.isChecked) {
"goingelectric"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else {
return@setOnClickListener
}
prefs.dataSource = result
prefs.filterStatus = FILTERS_DISABLED
okListener?.let { listener ->
listener(result)
}
prefs.dataSourceSet = true
dismiss()
}
}
}

View File

@@ -2,10 +2,13 @@ package net.vonforst.evmap.fragment
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Canvas
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
@@ -14,20 +17,29 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.car2go.maps.model.LatLng
import com.google.android.material.snackbar.Snackbar
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.FavoritesAdapter
import net.vonforst.evmap.databinding.FragmentFavoritesBinding
import net.vonforst.evmap.databinding.ItemFavoriteBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.FavoritesViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
private lateinit var binding: FragmentFavoritesBinding
private lateinit var locationClient: LostApiClient
private var toDelete: ChargeLocation? = null
private var deleteSnackbar: Snackbar? = null
private lateinit var adapter: FavoritesAdapter
private val vm: FavoritesViewModel by viewModels(factoryProducer = {
viewModelFactory {
@@ -66,13 +78,15 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
(requireActivity() as MapsActivity).appBarConfiguration
)
val favAdapter = FavoritesAdapter(vm).apply {
adapter = FavoritesAdapter(onDelete = {
delete(it.charger)
}).apply {
onClickListener = {
navController.navigate(R.id.action_favs_to_map, MapFragment.showCharger(it.charger))
}
}
binding.favsList.apply {
adapter = favAdapter
adapter = this@FavoritesFragment.adapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
@@ -81,6 +95,7 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
)
)
}
createTouchHelper().attachToRecyclerView(binding.favsList)
locationClient.connect()
}
@@ -102,4 +117,143 @@ class FavoritesFragment : Fragment(), LostApiClient.ConnectionCallbacks {
override fun onConnectionSuspended() {
}
override fun onDestroy() {
super.onDestroy()
if (locationClient.isConnected) {
locationClient.disconnect()
}
}
fun delete(fav: ChargeLocation) {
val position = vm.listData.value?.indexOfFirst { it.charger == fav } ?: return
// if there is already a profile to delete, delete it now
actuallyDelete()
deleteSnackbar?.dismiss()
toDelete = fav
view?.let {
val snackbar = Snackbar.make(
it,
getString(R.string.deleted_filterprofile, fav.name),
Snackbar.LENGTH_LONG
).setAction(R.string.undo) {
toDelete = null
adapter.notifyItemChanged(position)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
// if undo was not clicked, actually delete
if (event == DISMISS_EVENT_TIMEOUT || event == DISMISS_EVENT_SWIPE) {
actuallyDelete()
}
}
})
deleteSnackbar = snackbar
snackbar.show()
} ?: run {
actuallyDelete()
}
}
private fun actuallyDelete() {
toDelete?.let { vm.deleteFavorite(it) }
toDelete = null
}
private fun createTouchHelper(): ItemTouchHelper {
return ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0,
ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val fav = vm.favorites.value?.find { it.id == viewHolder.itemId }
fav?.let { delete(it) }
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (viewHolder != null && actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onSelected(binding.foreground)
} else {
super.onSelectedChanged(viewHolder, actionState)
}
}
override fun onChildDrawOver(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onDrawOver(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
)
val lp = (binding.deleteIcon.layoutParams as FrameLayout.LayoutParams)
lp.gravity = Gravity.CENTER_VERTICAL or if (dX > 0) {
Gravity.START
} else {
Gravity.END
}
binding.deleteIcon.layoutParams = lp
} else {
super.onChildDrawOver(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().clearView(binding.foreground)
}
override fun onChildDraw(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float,
actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val binding =
(viewHolder as DataBindingAdapter.ViewHolder<*>).binding as ItemFavoriteBinding
getDefaultUIUtil().onDraw(
c, recyclerView, binding.foreground, dX, dY,
actionState, isCurrentlyActive
)
} else {
super.onChildDraw(
c,
recyclerView,
viewHolder,
dX,
dY,
actionState,
isCurrentlyActive
)
}
}
})
}
}

View File

@@ -19,19 +19,11 @@ import net.vonforst.evmap.adapter.FiltersAdapter
import net.vonforst.evmap.databinding.FragmentFilterBinding
import net.vonforst.evmap.ui.showEditTextDialog
import net.vonforst.evmap.viewmodel.FilterViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class FilterFragment : Fragment() {
private lateinit var binding: FragmentFilterBinding
private val vm: FilterViewModel by viewModels(factoryProducer = {
viewModelFactory {
FilterViewModel(
requireActivity().application,
getString(R.string.goingelectric_key)
)
}
})
private val vm: FilterViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
@@ -59,6 +51,12 @@ class FilterFragment : Fragment() {
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.filterProfile.observe(viewLifecycleOwner) {
if (it != null) {
toolbar.title = "${getString(R.string.menu_filter)}: ${it.name}"
}
}
binding.filtersList.apply {
adapter = FiltersAdapter()
layoutManager =

View File

@@ -1,136 +0,0 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.core.app.SharedElementCallback
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import androidx.viewpager2.widget.ViewPager2
import coil.memory.MemoryCache
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.databinding.FragmentGalleryBinding
import net.vonforst.evmap.viewmodel.GalleryViewModel
class GalleryFragment : Fragment() {
companion object {
private const val EXTRA_POSITION = "position"
private const val EXTRA_PHOTOS = "photos"
private const val EXTRA_IMAGE_CACHE_KEY = "image_cache_key"
private const val SAVED_CURRENT_PAGE_POSITION = "current_page_position"
fun buildArgs(
photos: List<ChargerPhoto>,
position: Int,
imageCacheKey: MemoryCache.Key?
): Bundle {
return Bundle().apply {
putParcelableArrayList(EXTRA_PHOTOS, ArrayList(photos))
putInt(EXTRA_POSITION, position)
putParcelable(EXTRA_IMAGE_CACHE_KEY, imageCacheKey)
}
}
}
private lateinit var binding: FragmentGalleryBinding
private var startingPosition: Int = 0
private var currentPosition: Int = 0
private lateinit var galleryAdapter: GalleryAdapter
private var currentPage: TouchImageView? = null
private val galleryVm: GalleryViewModel by activityViewModels()
private val backPressedCallback = object :
OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val image = currentPage
if (image != null && image.currentZoom !in 0.95f..1.05f) {
image.setZoomAnimated(1f, 0.5f, 0.5f)
} else {
galleryVm.galleryPosition.value = currentPosition
findNavController().popBackStack()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_gallery, container, false
)
binding.lifecycleOwner = this
val args = requireArguments()
startingPosition = args.getInt(EXTRA_POSITION, 0)
currentPosition =
savedInstanceState?.getInt(SAVED_CURRENT_PAGE_POSITION) ?: startingPosition
galleryAdapter =
GalleryAdapter(
requireContext(), detailView = true, pageToLoad = currentPosition,
imageCacheKey = args.getParcelable(EXTRA_IMAGE_CACHE_KEY)
) {
startPostponedEnterTransition()
}
binding.gallery.setPageTransformer { page, _ ->
val v = page as TouchImageView
currentPage = v
}
binding.gallery.adapter = galleryAdapter
binding.photos = args.getParcelableArrayList(EXTRA_PHOTOS)
binding.gallery.post {
binding.gallery.setCurrentItem(currentPosition, false)
binding.gallery.registerOnPageChangeCallback(object :
ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
currentPosition = position
}
})
}
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(R.transition.image_shared_element_transition)
sharedElementReturnTransition = TransitionInflater.from(context)
.inflateTransition(R.transition.image_shared_element_transition)
setEnterSharedElementCallback(enterElementCallback)
if (savedInstanceState == null) {
postponeEnterTransition();
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
backPressedCallback
)
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(SAVED_CURRENT_PAGE_POSITION, currentPosition)
}
private val enterElementCallback: SharedElementCallback = object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
val currentPage = currentPage ?: return
sharedElements[names[0]] = currentPage
}
}
}

View File

@@ -10,8 +10,8 @@ import android.graphics.Color
import android.location.Geocoder
import android.location.Location
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -19,7 +19,6 @@ import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.app.SharedElementCallback
import androidx.core.content.ContextCompat
import androidx.core.view.MenuCompat
import androidx.core.view.updateLayoutParams
@@ -31,14 +30,16 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import coil.load
import coil.memory.MemoryCache
import coil.size.OriginalSize
import coil.size.SizeResolver
import com.car2go.maps.AnyMap
import com.car2go.maps.MapFragment
import com.car2go.maps.OnMapReadyCallback
@@ -57,6 +58,7 @@ import com.mapzen.android.lost.api.LocationListener
import com.mapzen.android.lost.api.LocationRequest
import com.mapzen.android.lost.api.LocationServices
import com.mapzen.android.lost.api.LostApiClient
import com.stfalcon.imageviewer.StfalconImageViewer
import io.michaelrocks.bimap.HashBiMap
import io.michaelrocks.bimap.MutableBiMap
import kotlinx.coroutines.Dispatchers
@@ -66,17 +68,17 @@ import net.vonforst.evmap.*
import net.vonforst.evmap.adapter.ConnectorAdapter
import net.vonforst.evmap.adapter.DetailsAdapter
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator
import net.vonforst.evmap.ui.MarkerAnimator
import net.vonforst.evmap.ui.getMarkerTint
import net.vonforst.evmap.utils.boundingBox
import net.vonforst.evmap.utils.distanceBetween
import net.vonforst.evmap.viewmodel.*
@@ -90,14 +92,7 @@ const val ARG_LOCATION_NAME = "locationName"
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback,
LostApiClient.ConnectionCallbacks, LocationListener {
private lateinit var binding: FragmentMapBinding
private val vm: MapViewModel by viewModels(factoryProducer = {
viewModelFactory {
MapViewModel(
requireActivity().application,
getString(R.string.goingelectric_key)
)
}
})
private val vm: MapViewModel by viewModels()
private val galleryVm: GalleryViewModel by activityViewModels()
private var mapFragment: MapFragment? = null
private var map: AnyMap? = null
@@ -180,7 +175,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
setHasOptionsMenu(true)
postponeEnterTransition()
binding.root.setOnApplyWindowInsetsListener { _, insets ->
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -201,7 +195,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
insets
}
setExitSharedElementCallback(reenterSharedElementCallback)
exitTransition = TransitionInflater.from(requireContext())
.inflateTransition(R.transition.map_exit_transition)
@@ -220,38 +213,34 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.detailAppBar.toolbar.inflateMenu(R.menu.detail)
favToggle = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_fav)
binding.detailAppBar.toolbar.menu.findItem(R.id.menu_edit).title =
getString(R.string.edit_at_datasource, vm.apiName)
setupObservers()
setupClickListeners()
setupAdapters()
val navController = findNavController()
(activity as? MapsActivity)?.setSupportActionBar(binding.toolbar)
binding.toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
val prefs = PreferenceDataSource(requireContext())
if (!prefs.welcomeDialogShown) {
try {
navController.navigate(R.id.action_map_to_welcome)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
} else if (!prefs.update060AndroidAutoDialogShown) {
/*if (!prefs.update060AndroidAutoDialogShown) {
try {
navController.navigate(R.id.action_map_to_update_060_androidauto)
} catch (ignored: IllegalArgumentException) {
// when there is already another navigation going on
}
}
}*/
}
override fun onResume() {
super.onResume()
val hostActivity = activity as? MapsActivity ?: return
hostActivity.fragmentCallback = this
val navController = findNavController()
binding.toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
vm.reloadPrefs()
if (requestingLocationUpdates && ContextCompat.checkSelfPermission(
requireContext(),
@@ -288,24 +277,27 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
binding.fabLayers.setOnClickListener {
openLayersMenu()
}
binding.detailView.goingelectricButton.setOnClickListener {
binding.layers.btnClose.setOnClickListener {
closeLayersMenu()
}
binding.detailView.sourceButton.setOnClickListener {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
(activity as? MapsActivity)?.openUrl(charger.url)
}
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
findNavController().navigate(
R.id.action_map_to_chargepriceFragment,
ChargepriceFragment.showCharger(charger)
ChargepriceFragment.showCharger(charger, vm.apiType)
)
}
binding.detailView.topPart.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT
}
binding.search.setOnClickListener {
launchAutocomplete(this)
launchAutocomplete(this, vm.location.value)
}
binding.detailAppBar.toolbar.setNavigationOnClickListener {
bottomSheetBehavior.state = STATE_COLLAPSED
@@ -319,19 +311,22 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
R.id.menu_share -> {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.shareUrl("https:${charger.url}")
(activity as? MapsActivity)?.shareUrl(charger.url)
}
true
}
R.id.menu_edit -> {
val charger = vm.charger.value?.data
if (charger != null) {
(activity as? MapsActivity)?.openUrl("https:${charger.url}edit/")
Toast.makeText(
requireContext(),
R.string.edit_on_goingelectric_info,
Toast.LENGTH_LONG
).show()
if (charger?.editUrl != null) {
(activity as? MapsActivity)?.openUrl(charger.editUrl)
if (vm.apiType == GoingElectricApiWrapper::class.java) {
// instructions specific to GoingElectric
Toast.makeText(
requireContext(),
R.string.edit_on_goingelectric_info,
Toast.LENGTH_LONG
).show()
}
}
true
}
@@ -542,24 +537,35 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
private fun setupAdapters() {
var viewer: StfalconImageViewer<ChargerPhoto>? = null
val galleryClickListener = object : GalleryAdapter.ItemClickListener {
override fun onItemClick(view: View, position: Int, imageCacheKey: MemoryCache.Key?) {
val photos = vm.charger.value?.data?.photos ?: return
val extras = FragmentNavigatorExtras(view to view.transitionName)
view.findNavController().navigate(
R.id.action_map_to_galleryFragment,
GalleryFragment.buildArgs(photos, position, imageCacheKey),
null,
extras
)
viewer = StfalconImageViewer.Builder(context, photos) { imageView, photo ->
imageView.load(photo.getUrl(size = 1000)) {
if (photo == photos[position] && imageCacheKey != null) {
placeholderMemoryCacheKey(imageCacheKey)
}
size(SizeResolver(OriginalSize))
allowHardware(false)
}
}
.withTransitionFrom(view as ImageView)
.withImageChangeListener {
binding.gallery.layoutManager!!.scrollToPosition(it)
binding.gallery.layoutManager!!.findViewByPosition(it)?.let {
viewer?.updateTransitionImage(it as ImageView)
}
}
.withStartPosition(position)
.show()
}
}
val galleryPosition = galleryVm.galleryPosition.value
binding.gallery.apply {
adapter = GalleryAdapter(context, galleryClickListener, pageToLoad = galleryPosition) {
startPostponedEnterTransition()
}
adapter = GalleryAdapter(context, galleryClickListener)
itemAnimator = null
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
@@ -570,41 +576,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
setDrawable(ContextCompat.getDrawable(context, R.drawable.gallery_divider)!!)
})
}
if (galleryPosition == null) {
startPostponedEnterTransition()
} else {
binding.gallery.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
v.removeOnLayoutChangeListener(this)
val layoutManager = binding.gallery.layoutManager!!
val viewAtPosition = layoutManager.findViewByPosition(galleryPosition)
if (viewAtPosition == null || layoutManager.isViewPartiallyVisible(
viewAtPosition,
false,
true
)
) {
binding.gallery.post {
layoutManager.scrollToPosition(galleryPosition)
}
}
}
})
// make sure that the app does not freeze waiting for a picture to load
Handler().postDelayed({
startPostponedEnterTransition()
}, 100)
}
binding.detailView.connectors.apply {
adapter = ConnectorAdapter()
@@ -623,7 +594,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
(activity as? MapsActivity)?.showLocation(charger)
}
R.drawable.ic_fault_report -> {
(activity as? MapsActivity)?.openUrl("https:${charger.url}")
(activity as? MapsActivity)?.openUrl(charger.url)
}
R.drawable.ic_payment -> {
showPaymentMethodsDialog(charger)
@@ -782,7 +753,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
positionSet = true
} else if (lat != null && lon != null) {
// show given position
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f)
val latLng = LatLng(lat, lon)
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
map.moveCamera(cameraUpdate)
if (chargerId != null) {
@@ -802,7 +774,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
})
} else {
// mark location as search result
vm.searchResult.value = PlaceWithBounds(LatLng(lat, lon), null)
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, 750.0))
}
positionSet = true
@@ -815,7 +787,16 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val latLng = LatLng(it.latitude, it.longitude)
val cameraUpdate = map.cameraUpdateFactory.newLatLngZoom(latLng, 16f)
map.moveCamera(cameraUpdate)
vm.searchResult.value = PlaceWithBounds(latLng, null)
val bboxSize = if (it.subAdminArea != null) {
750.0 // this is a place within a city
} else if (it.adminArea != null && it.adminArea != it.featureName) {
4000.0 // this is a city
} else if (it.adminArea != null) {
100000.0 // this is a top-level administrative area (i.e. state)
} else {
500000.0 // this is a country
}
vm.searchResult.value = PlaceWithBounds(latLng, boundingBox(latLng, bboxSize))
}
}
}
@@ -1106,23 +1087,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
return binding.root
}
private val reenterSharedElementCallback: SharedElementCallback =
object : SharedElementCallback() {
override fun onMapSharedElements(
names: MutableList<String>,
sharedElements: MutableMap<String, View>
) {
// Locate the ViewHolder for the clicked position.
val position = galleryVm.galleryPosition.value ?: return
val vh = binding.gallery.findViewHolderForAdapterPosition(position)
if (vh?.itemView == null) return
// Map the first shared element name to the child ImageView.
sharedElements[names[0]] = vh.itemView
}
}
companion object {
fun showCharger(charger: ChargeLocation): Bundle {
return Bundle().apply {
@@ -1217,4 +1181,11 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
super.onPause()
removeLocationUpdates()
}
override fun onDestroy() {
super.onDestroy()
if (locationClient.isConnected) {
locationClient.disconnect()
}
}
}

View File

@@ -21,7 +21,8 @@ class MultiSelectDialog : AppCompatDialogFragment() {
title: String,
data: Map<String, String>,
selected: Set<String>,
commonChoices: Set<String>?
commonChoices: Set<String>?,
showAllButton: Boolean = true
): MultiSelectDialog {
val dialog = MultiSelectDialog()
dialog.arguments = Bundle().apply {
@@ -29,6 +30,7 @@ class MultiSelectDialog : AppCompatDialogFragment() {
putSerializable("data", HashMap(data))
putSerializable("selected", HashSet(selected))
if (commonChoices != null) putSerializable("commonChoices", HashSet(commonChoices))
putBoolean("showAllButton", showAllButton)
}
return dialog
}
@@ -66,12 +68,15 @@ class MultiSelectDialog : AppCompatDialogFragment() {
val commonChoices = if (args.containsKey("commonChoices")) {
args.getSerializable("commonChoices") as HashSet<String>
} else null
val showAllButton = args.getBoolean("showAllButton")
binding.dialogTitle.text = title
val adapter = Adapter()
binding.list.adapter = adapter
binding.list.layoutManager = LinearLayoutManager(view.context)
binding.btnAll.visibility = if (showAllButton) View.VISIBLE else View.INVISIBLE
items = data.entries.toList()
.sortedBy { it.value.toLowerCase(Locale.getDefault()) }
.sortedByDescending { commonChoices?.contains(it.key) == true }

View File

@@ -0,0 +1,252 @@
package net.vonforst.evmap.fragment
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
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.navigation.fragment.findNavController
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.*
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.PreferenceDataSource
class OnboardingFragment : Fragment() {
private lateinit var binding: FragmentOnboardingBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingBinding.inflate(inflater)
val adapter = OnboardingViewPagerAdapter(this)
binding.viewPager.adapter = adapter
binding.pageIndicatorView.count = adapter.itemCount
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
binding.pageIndicatorView.onPageScrollStateChanged(state)
}
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
binding.pageIndicatorView.onPageScrolled(
position,
positionOffset,
positionOffsetPixels
)
}
override fun onPageSelected(position: Int) {
binding.pageIndicatorView.selection = position
}
})
return binding.root
}
fun goToNext() {
if (binding.viewPager.currentItem == 2) {
findNavController().navigate(R.id.action_onboarding_to_map)
} else {
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
}
}
}
class OnboardingViewPagerAdapter(fragment: Fragment) :
FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> WelcomeFragment()
1 -> IconsFragment()
2 -> DataSourceSelectFragment()
else -> throw IllegalArgumentException()
}
}
abstract class OnboardingPageFragment : Fragment() {
lateinit var parent: OnboardingFragment
override fun onAttach(context: Context) {
super.onAttach(context)
parent = parentFragment as OnboardingFragment
}
}
class WelcomeFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingWelcomeBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingWelcomeBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
return binding.root
}
override fun onResume() {
super.onResume()
binding.animationView.playAnimation()
}
override fun onPause() {
super.onPause()
binding.animationView.progress = 0f
}
}
class IconsFragment : OnboardingPageFragment() {
private lateinit var binding: FragmentOnboardingIconsBinding
val labels
get() = listOf(
binding.iconLabel1,
binding.iconLabel2,
binding.iconLabel3,
binding.iconLabel4,
binding.iconLabel5
)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingIconsBinding.inflate(inflater, container, false)
binding.btnGetStarted.setOnClickListener {
parent.goToNext()
}
labels.forEach { it.alpha = 0f }
return binding.root
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators = labels.flatMapIndexed { i, view ->
listOf(
ObjectAnimator.ofFloat(view, "translationY", -20f, 0f).apply {
startDelay = 40L * i
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(view, "alpha", 0f, 1f).apply {
startDelay = 40L * i
interpolator = DecelerateInterpolator()
}
)
}
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
labels.forEach { it.alpha = 0f }
}
}
class DataSourceSelectFragment : OnboardingPageFragment() {
private lateinit var prefs: PreferenceDataSource
private lateinit var binding: FragmentOnboardingDataSourceBinding
val animatedItems
get() = listOf(
binding.rgDataSource.rbGoingElectric,
binding.rgDataSource.textView27,
binding.rgDataSource.rbOpenChargeMap,
binding.rgDataSource.textView28
)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentOnboardingDataSourceBinding.inflate(inflater, container, false)
prefs = PreferenceDataSource(requireContext())
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnGetStarted.visibility = View.INVISIBLE
for (rb in listOf(
binding.rgDataSource.rbGoingElectric,
binding.rgDataSource.rbOpenChargeMap
)) {
rb.setOnCheckedChangeListener { _, _ ->
if (binding.btnGetStarted.visibility == View.INVISIBLE) {
binding.btnGetStarted.visibility = View.VISIBLE
ObjectAnimator.ofFloat(binding.btnGetStarted, "alpha", 0f, 1f).apply {
interpolator = DecelerateInterpolator()
}.start()
}
}
}
binding.btnGetStarted.setOnClickListener {
val result = if (binding.rgDataSource.rbGoingElectric.isChecked) {
"goingelectric"
} else if (binding.rgDataSource.rbOpenChargeMap.isChecked) {
"openchargemap"
} else {
return@setOnClickListener
}
prefs.dataSource = result
prefs.filterStatus = FILTERS_DISABLED
prefs.dataSourceSet = true
prefs.welcomeDialogShown = true
parent.goToNext()
}
animatedItems.forEach { it.alpha = 0f }
}
@SuppressLint("Recycle")
override fun onResume() {
super.onResume()
val animators = animatedItems.flatMapIndexed { i, view ->
listOf(
ObjectAnimator.ofFloat(view, "translationY", 20f, 0f).apply {
startDelay = 40L * i
interpolator = DecelerateInterpolator()
},
ObjectAnimator.ofFloat(view, "alpha", 0f, 1f).apply {
startDelay = 40L * i
interpolator = DecelerateInterpolator()
}
)
}
AnimatorSet().apply {
playTogether(animators)
start()
}
}
override fun onPause() {
super.onPause()
animatedItems.forEach { it.alpha = 0f }
}
}

View File

@@ -7,7 +7,6 @@ import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@@ -32,20 +31,13 @@ class SettingsFragment : PreferenceFragmentCompat(),
}
})
private lateinit var myVehiclePreference: ListPreference
private lateinit var myVehiclePreference: MultiSelectListPreference
private lateinit var myTariffsPreference: MultiSelectListPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
prefs = PreferenceDataSource(requireContext())
val navController = findNavController()
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
vm.vehicles.observe(viewLifecycleOwner) { res ->
@@ -55,12 +47,12 @@ class SettingsFragment : PreferenceFragmentCompat(),
myVehiclePreference.entries =
sortedCars.map { "${it.brand} ${it.name}" }.toTypedArray()
myVehiclePreference.isEnabled = true
myVehiclePreference.summary = cars.find { it.id == prefs.chargepriceMyVehicle }
?.let { "${it.brand} ${it.name}" }
updateMyVehiclesSummary()
}
}
myTariffsPreference = findPreference("chargeprice_my_tariffs")!!
myTariffsPreference.isEnabled = false
vm.tariffs.observe(viewLifecycleOwner) { res ->
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
@@ -87,6 +79,17 @@ class SettingsFragment : PreferenceFragmentCompat(),
}
}
private fun updateMyVehiclesSummary() {
vm.vehicles.value?.data?.let { cars ->
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
val summary = vehicles.map {
"${it.brand} ${it.name}"
}.joinToString(", ")
myVehiclePreference.summary = summary
// TODO: prefs.chargepriceMyVehicleDcChargeports = it.dcChargePorts
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
}
@@ -109,13 +112,7 @@ class SettingsFragment : PreferenceFragmentCompat(),
updateNightMode(prefs)
}
"chargeprice_my_vehicle" -> {
vm.vehicles.value?.data?.let { cars ->
val vehicle = cars.find { it.id == prefs.chargepriceMyVehicle }
vehicle?.let {
myVehiclePreference.summary = "${it.brand} ${it.name}"
prefs.chargepriceMyVehicleDcChargeports = it.dcChargePorts
}
}
updateMyVehiclesSummary()
}
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
@@ -126,6 +123,13 @@ class SettingsFragment : PreferenceFragmentCompat(),
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val navController = findNavController()
val toolbar = requireView().findViewById(R.id.toolbar) as Toolbar
toolbar.setupWithNavController(
navController,
(requireActivity() as MapsActivity).appBarConfiguration
)
}
override fun onPause() {

View File

@@ -1,42 +0,0 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AppCompatDialogFragment
import net.vonforst.evmap.databinding.DialogWelcomeBinding
import net.vonforst.evmap.storage.PreferenceDataSource
class WelcomeDialogFragment : AppCompatDialogFragment() {
private lateinit var binding: DialogWelcomeBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogWelcomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnOk.setOnClickListener {
val prefs = PreferenceDataSource(requireContext())
prefs.welcomeDialogShown = true
prefs.update060AndroidAutoDialogShown = true
dismiss()
}
}
override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}

View File

@@ -0,0 +1,329 @@
package net.vonforst.evmap.model
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.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.nameForPlugType
import java.time.DayOfWeek
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
sealed class ChargepointListItem
@Entity(primaryKeys = ["id", "dataSource"])
@Parcelize
data class ChargeLocation(
val id: Long,
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
val editUrl: String?,
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
val verified: Boolean,
val barrierFree: Boolean?,
// only shown in details:
val operator: String?,
val generalInformation: String?,
val amenities: String?,
val locationDescription: String?,
val photos: List<ChargerPhoto>?,
val chargecards: List<ChargeCardId>?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?,
val license: String?,
@Embedded(prefix = "chargeprice") val chargepriceData: ChargepriceData?
) : ChargepointListItem(), Equatable, Parcelable {
/**
* 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
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
/**
* 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(sp: StringProvider): String {
return chargepointsMerged.map {
"${it.count} × ${nameForPlugType(sp, it.type)} ${it.formatPower()}"
}.joinToString(" · ")
}
}
/**
* Additional data needed for the Chargeprice implementation
*/
@Parcelize
data class ChargepriceData(
val country: String?,
val network: String?,
val plugTypes: List<String>?
) : Parcelable
@Parcelize
data class Cost(
val freecharging: Boolean? = null,
val freeparking: Boolean? = null,
val descriptionShort: String? = null,
val descriptionLong: String? = null
) : Parcelable {
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
if (freecharging != null && freeparking != null) {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
} else if (descriptionShort != null) {
return descriptionShort
} else if (descriptionLong != null) {
return descriptionLong
} else {
return ""
}
}
}
@Parcelize
data class OpeningHours(
val twentyfourSeven: Boolean,
val description: String?,
@Embedded val days: OpeningHoursDays?
) : Parcelable {
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 ""
}
}
}
@Parcelize
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
) : Parcelable {
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
}
}
}
@Parcelize
data class Hours(
val start: LocalTime?,
val end: LocalTime?
) : Parcelable {
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"
}
}
}
abstract class ChargerPhoto(open val id: String) : Parcelable {
abstract fun getUrl(height: Int? = null, width: Int? = null, size: Int? = null): String
}
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
) : ChargepointListItem()
@Parcelize
data class Coordinate(val lat: Double, val lng: Double) : Parcelable {
fun formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun formatDecimal(): String {
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
}
}
@Parcelize
data class Address(
val city: String?,
val country: String?,
val postcode: String?,
val street: String?
) : Parcelable {
override fun toString(): String {
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
}
@Parcelize
@JsonClass(generateAdapter = true)
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable,
Parcelable {
fun formatPower(): String {
val powerFmt = if (power - power.toInt() == 0.0) {
"%.0f".format(power)
} else {
"%.1f".format(power)
}
return "$powerFmt kW"
}
companion object {
const val TYPE_1 = "Type 1"
const val TYPE_2_UNKNOWN = "Type 2 (either plug or socket)"
const val TYPE_2_SOCKET = "Type 2 socket"
const val TYPE_2_PLUG = "Type 2 plug"
const val TYPE_3 = "Type 3"
const val CCS_TYPE_2 = "CCS Type 2"
const val CCS_TYPE_1 = "CCS Type 1"
const val CCS_UNKNOWN = "CCS (either Type 1 or Type 2)"
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"
}
}
@Parcelize
data class FaultReport(val created: Instant?, val description: String?) : Parcelable
@Entity
data class ChargeCard(
@PrimaryKey val id: Long,
val name: String,
val url: String
)
@Parcelize
@JsonClass(generateAdapter = true)
data class ChargeCardId(
val id: Long
) : Parcelable

View File

@@ -0,0 +1,132 @@
package net.vonforst.evmap.model
import androidx.databinding.BaseObservable
import androidx.room.Entity
import androidx.room.ForeignKey
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.storage.FilterProfile
import kotlin.reflect.KClass
sealed class Filter<out T : FilterValue> : Equatable {
abstract val name: String
abstract val key: String
abstract val valueClass: KClass<out T>
abstract fun defaultValue(): T
}
data class BooleanFilter(override val name: String, override val key: String) :
Filter<BooleanFilterValue>() {
override val valueClass: KClass<BooleanFilterValue> = BooleanFilterValue::class
override fun defaultValue() = BooleanFilterValue(key, false)
}
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>,
val commonChoices: Set<String>? = null,
val manyChoices: Boolean = false
) : Filter<MultipleChoiceFilterValue>() {
override val valueClass: KClass<MultipleChoiceFilterValue> = MultipleChoiceFilterValue::class
override fun defaultValue() = MultipleChoiceFilterValue(key, mutableSetOf(), true)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int,
val min: Int = 0,
val mapping: ((Int) -> Int) = { it },
val inverseMapping: ((Int) -> Int) = { it },
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var dataSource: String = ""
var profile: Long = FILTERS_CUSTOM
abstract fun hasSameValueAs(other: FilterValue): Boolean
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id", "dataSource"),
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
)
data class BooleanFilterValue(
override val key: String,
var value: Boolean
) : FilterValue() {
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is BooleanFilterValue && other.value == this.value
}
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id", "dataSource"),
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
)
data class MultipleChoiceFilterValue(
override val key: String,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is MultipleChoiceFilterValue && if (other.all) {
this.all
} else {
!this.all && other.values == this.values
}
}
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id", "dataSource"),
childColumns = arrayOf("profile", "dataSource"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile", "dataSource"]
)
data class SliderFilterValue(
override val key: String,
var value: Int
) : FilterValue() {
override fun hasSameValueAs(other: FilterValue): Boolean {
return other is SliderFilterValue && other.value == this.value
}
}
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }?.value as BooleanFilterValue?)?.value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }?.value as SliderFilterValue?)?.value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }?.filter as MultipleChoiceFilter?
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }?.value as MultipleChoiceFilterValue?
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L

View File

@@ -0,0 +1,3 @@
package net.vonforst.evmap.model
abstract class ReferenceData

View File

@@ -13,11 +13,12 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import net.vonforst.evmap.R
import net.vonforst.evmap.storage.PreferenceDataSource
@Navigator.Name("chrome")
class ChromeCustomTabsNavigator(
@Navigator.Name("custom")
class CustomNavigator(
private val context: Context
) : Navigator<ChromeCustomTabsNavigator.Destination>() {
) : Navigator<CustomNavigator.Destination>() {
override fun createDestination() =
Destination(this)
@@ -28,6 +29,19 @@ class ChromeCustomTabsNavigator(
navOptions: NavOptions?,
navigatorExtras: Extras?
): NavDestination? {
if (destination.destination == "report_new_charger") {
val prefs = PreferenceDataSource(context)
val url = when (prefs.dataSource) {
"goingelectric" -> "https://www.goingelectric.de/stromtankstellen/new/"
"openchargemap" -> "https://openchargemap.org/site/poi/add"
else -> throw IllegalArgumentException()
}
launchCustomTab(url)
}
return null // Do not add to the back stack, managed by Chrome Custom Tabs
}
fun launchCustomTab(url: String) {
val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
@@ -35,20 +49,19 @@ class ChromeCustomTabsNavigator(
.build()
)
.build()
intent.launchUrl(context, destination.url!!)
return null // Do not add to the back stack, managed by Chrome Custom Tabs
intent.launchUrl(context, Uri.parse(url))
}
override fun popBackStack() = true // Managed by Chrome Custom Tabs
@NavDestination.ClassType(Activity::class)
class Destination(navigator: Navigator<out NavDestination>) : NavDestination(navigator) {
var url: Uri? = null
lateinit var destination: String
override fun onInflate(context: Context, attrs: AttributeSet) {
super.onInflate(context, attrs)
context.withStyledAttributes(attrs, R.styleable.ChromeCustomTabsNavigator, 0, 0) {
url = Uri.parse(getString(R.styleable.ChromeCustomTabsNavigator_url))
context.withStyledAttributes(attrs, R.styleable.CustomNavigator, 0, 0) {
destination = getString(R.styleable.CustomNavigator_customDestination)!!
}
}
}

View File

@@ -7,7 +7,7 @@ class NavHostFragment : NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider.addNavigator(
ChromeCustomTabsNavigator(
CustomNavigator(
requireContext()
)
)

View File

@@ -1,54 +0,0 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.io.IOException
import java.time.Duration
import java.time.Instant
@Dao
interface ChargeCardDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg chargeCards: ChargeCard)
@Delete
suspend fun delete(vararg chargeCards: ChargeCard)
@Query("SELECT * FROM chargeCard")
fun getAllChargeCards(): LiveData<List<ChargeCard>>
}
class ChargeCardRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: ChargeCardDao, private val prefs: PreferenceDataSource
) {
fun getChargeCards(): LiveData<List<ChargeCard>> {
scope.launch {
updateChargeCards()
}
return dao.getAllChargeCards()
}
private suspend fun updateChargeCards() {
if (Duration.between(prefs.lastChargeCardUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getChargeCards()
if (!response.isSuccessful) return
for (card in response.body()!!.result) {
dao.insert(card)
}
prefs.lastChargeCardUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -2,13 +2,16 @@ package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.model.ChargeLocation
@Dao
interface ChargeLocationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg locations: ChargeLocation)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertBlocking(vararg locations: ChargeLocation)
@Delete
suspend fun delete(vararg locations: ChargeLocation)
@@ -17,4 +20,7 @@ interface ChargeLocationsDao {
@Query("SELECT * FROM chargelocation")
suspend fun getAllChargeLocationsAsync(): List<ChargeLocation>
@Query("SELECT * FROM chargelocation")
fun getAllChargeLocationsBlocking(): List<ChargeLocation>
}

View File

@@ -1,18 +1,20 @@
package net.vonforst.evmap.storage
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.viewmodel.BooleanFilterValue
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
import net.vonforst.evmap.viewmodel.SliderFilterValue
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.openchargemap.OCMConnectionType
import net.vonforst.evmap.api.openchargemap.OCMCountry
import net.vonforst.evmap.api.openchargemap.OCMOperator
import net.vonforst.evmap.model.*
@Database(
entities = [
@@ -21,19 +23,25 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
MultipleChoiceFilterValue::class,
SliderFilterValue::class,
FilterProfile::class,
Plug::class,
Network::class,
ChargeCard::class
], version = 11
GEPlug::class,
GENetwork::class,
GEChargeCard::class,
OCMConnectionType::class,
OCMCountry::class,
OCMOperator::class
], version = 13
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun filterProfileDao(): FilterProfileDao
abstract fun plugDao(): PlugDao
abstract fun networkDao(): NetworkDao
abstract fun chargeCardDao(): ChargeCardDao
// GoingElectric API specific
abstract fun geReferenceDataDao(): GEReferenceDataDao
// OpenChargeMap API specific
abstract fun ocmReferenceDataDao(): OCMReferenceDataDao
companion object {
private lateinit var context: Context
@@ -41,12 +49,14 @@ abstract class AppDatabase : RoomDatabase() {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11
MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11,
MIGRATION_12, MIGRATION_13
)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
// create default filter profile
db.execSQL("INSERT INTO `FilterProfile` (`name`, `id`, `order`) VALUES ('FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
// create default filter profile for each data source
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
}
})
.build()
@@ -176,5 +186,120 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE `ChargeLocation` ADD `barrierFree` INTEGER")
}
}
private val MIGRATION_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
//////////////////////////////////////////
// create OpenChargeMap-specific tables //
//////////////////////////////////////////
db.execSQL("CREATE TABLE `OCMConnectionType` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `formalName` TEXT, `discontinued` INTEGER, `obsolete` INTEGER, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMCountry` (`id` INTEGER NOT NULL, `isoCode` TEXT NOT NULL, `continentCode` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))");
//////////////////////////////////////////
// rename GoingElectric-specific tables //
//////////////////////////////////////////
db.execSQL("ALTER TABLE `ChargeCard` RENAME TO `GEChargeCard`")
db.execSQL("ALTER TABLE `Network` RENAME TO `GENetwork`")
db.execSQL("ALTER TABLE `Plug` RENAME TO `GEPlug`")
/////////////////////////////////////////////
// add new columns to ChargeLocation table //
/////////////////////////////////////////////
db.execSQL("ALTER TABLE `ChargeLocation` ADD `editUrl` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `license` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargepricecountry` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargepricenetwork` TEXT")
db.execSQL("ALTER TABLE `ChargeLocation` ADD `chargepriceplugTypes` TEXT")
////////////////////////////////////////////////////////////
// Separate FilterValues and FilterProfiles by DataSource //
////////////////////////////////////////////////////////////
// recreate tables
db.execSQL("CREATE TABLE `FilterProfileNew` (`name` TEXT NOT NULL, `dataSource` TEXT NOT NULL, `id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`dataSource`, `id`))")
db.execSQL("CREATE UNIQUE INDEX `index_FilterProfile_dataSource_name` ON `FilterProfileNew` (`dataSource`, `name`)")
db.execSQL("CREATE TABLE `BooleanFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE `MultipleChoiceFilterValueNew` (`key` TEXT NOT NULL, `values` TEXT NOT NULL, `all` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE `SliderFilterValueNew` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `profile` INTEGER NOT NULL, PRIMARY KEY(`key`, `profile`, `dataSource`), FOREIGN KEY(`profile`, `dataSource`) REFERENCES `FilterProfile`(`id`, `dataSource`) ON UPDATE NO ACTION ON DELETE CASCADE )")
val tables = listOf(
"FilterProfile",
"BooleanFilterValue",
"MultipleChoiceFilterValue",
"SliderFilterValue",
)
// copy data
for (table in tables) {
val columnList = when (table) {
"BooleanFilterValue", "SliderFilterValue" -> "`key`, `value`, `dataSource`, `profile`"
"MultipleChoiceFilterValue" -> "`key`, `values`, `all`, `dataSource`, `profile`"
"FilterProfile" -> "`name`, `dataSource`, `id`, `order`"
else -> throw IllegalArgumentException()
}
db.execSQL("ALTER TABLE `$table` ADD COLUMN `dataSource` STRING NOT NULL DEFAULT 'goingelectric'")
db.execSQL("INSERT INTO `${table}New`($columnList) SELECT $columnList FROM `$table`")
db.execSQL("DROP TABLE `$table`")
db.execSQL("ALTER TABLE `${table}New` RENAME TO `$table`")
}
// create default filter profile for openchargemap
db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
private val MIGRATION_13 = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
// add column dataSource to ChargeLocation table
db.execSQL("ALTER TABLE `ChargeLocation` ADD `dataSource` TEXT NOT NULL DEFAULT 'openchargemap'")
// this should have been included in MIGRATION_12:
// Update GoingElectric format of plug types for favorites to generic EVMap format
val cursor = db.query("SELECT * FROM `ChargeLocation`")
while (cursor.moveToNext()) {
val chargepoints =
Converters().toChargepointList(cursor.getString(cursor.getColumnIndex("chargepoints")))!!
val updated = chargepoints.map {
it.copy(type = GEChargepoint.convertTypeFromGE(it.type))
}
if (updated != chargepoints) {
db.update(
"ChargeLocation",
SQLiteDatabase.CONFLICT_ROLLBACK,
ContentValues().apply {
put("chargepoints", Converters().fromChargepointList(updated))
put("dataSource", "goingelectric")
},
"id = ?",
arrayOf(cursor.getLong(cursor.getColumnIndex("id")))
)
}
}
// update ChargeLocation table to change primary key
db.execSQL(
"CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `url` TEXT NOT NULL, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, PRIMARY KEY(`id`, `dataSource`))"
);
val columnList =
"`id`,`dataSource`,`name`,`chargepoints`,`network`,`url`,`editUrl`,`verified`,`barrierFree`,`operator`,`generalInformation`,`amenities`,`locationDescription`,`photos`,`chargecards`,`license`,`lat`,`lng`,`city`,`country`,`postcode`,`street`,`fault_report_created`,`fault_report_description`,`twentyfourSeven`,`description`,`mostart`,`moend`,`tustart`,`tuend`,`westart`,`weend`,`thstart`,`thend`,`frstart`,`frend`,`sastart`,`saend`,`sustart`,`suend`,`hostart`,`hoend`,`freecharging`,`freeparking`,`descriptionShort`,`descriptionLong`,`chargepricecountry`,`chargepricenetwork`,`chargepriceplugTypes`"
db.execSQL("INSERT INTO `ChargeLocationNew`($columnList) SELECT $columnList FROM `ChargeLocation`")
db.execSQL("DROP TABLE `ChargeLocation`")
db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`")
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
}
}

View File

@@ -3,14 +3,16 @@ package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_CUSTOM
@Entity(
indices = [Index(value = ["name"], unique = true)]
indices = [Index(value = ["dataSource", "name"], unique = true)],
primaryKeys = ["dataSource", "id"],
)
data class FilterProfile(
val name: String,
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dataSource: String,
val id: Long,
var order: Int = 0
) : Equatable
@@ -25,12 +27,15 @@ interface FilterProfileDao {
@Delete
suspend fun delete(vararg profiles: FilterProfile)
@Query("SELECT * FROM filterProfile WHERE id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
fun getProfiles(): LiveData<List<FilterProfile>>
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND id != $FILTERS_CUSTOM ORDER BY `order` ASC, `name` ASC")
fun getProfiles(dataSource: String): LiveData<List<FilterProfile>>
@Query("SELECT * FROM filterProfile WHERE name = :name")
suspend fun getProfileByName(name: String): FilterProfile?
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND name = :name")
suspend fun getProfileByName(name: String, dataSource: String): FilterProfile?
@Query("SELECT * FROM filterProfile WHERE id = :id")
suspend fun getProfileById(id: Long): FilterProfile?
@Query("SELECT * FROM filterProfile WHERE dataSource = :dataSource AND id = :id")
suspend fun getProfileById(id: Long, dataSource: String): FilterProfile?
@Query("SELECT (MAX(id) + 1) FROM filterProfile WHERE dataSource = :dataSource")
suspend fun getNewId(dataSource: String): Long
}

View File

@@ -4,18 +4,27 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.*
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.model.*
@Dao
abstract class FilterValueDao {
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile")
protected abstract fun getBooleanFilterValues(profile: Long): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getBooleanFilterValues(
profile: Long,
dataSource: String
): LiveData<List<BooleanFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile")
protected abstract fun getMultipleChoiceFilterValues(profile: Long): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getMultipleChoiceFilterValues(
profile: Long,
dataSource: String
): LiveData<List<MultipleChoiceFilterValue>>
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile")
protected abstract fun getSliderFilterValues(profile: Long): LiveData<List<SliderFilterValue>>
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract fun getSliderFilterValues(
profile: Long,
dataSource: String
): LiveData<List<SliderFilterValue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
@@ -26,28 +35,40 @@ abstract class FilterValueDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(vararg values: SliderFilterValue)
@Query("DELETE FROM booleanfiltervalue WHERE profile = :profile")
protected abstract suspend fun deleteBooleanFilterValuesForProfile(profile: Long)
@Query("DELETE FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun deleteBooleanFilterValuesForProfile(
profile: Long,
dataSource: String
)
@Query("DELETE FROM multiplechoicefiltervalue WHERE profile = :profile")
protected abstract suspend fun deleteMultipleChoiceFilterValuesForProfile(profile: Long)
@Query("DELETE FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun deleteMultipleChoiceFilterValuesForProfile(
profile: Long,
dataSource: String
)
@Query("DELETE FROM sliderfiltervalue WHERE profile = :profile")
protected abstract suspend fun deleteSliderFilterValuesForProfile(profile: Long)
@Query("DELETE FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
protected abstract suspend fun deleteSliderFilterValuesForProfile(
profile: Long,
dataSource: String
)
open fun getFilterValues(filterStatus: Long): LiveData<List<FilterValue>> =
open fun getFilterValues(filterStatus: Long, dataSource: String): LiveData<List<FilterValue>> =
if (filterStatus == FILTERS_DISABLED) {
MutableLiveData(emptyList())
} else {
MediatorLiveData<List<FilterValue>>().apply {
val sources = listOf(
getBooleanFilterValues(filterStatus),
getMultipleChoiceFilterValues(filterStatus),
getSliderFilterValues(filterStatus)
getBooleanFilterValues(filterStatus, dataSource),
getMultipleChoiceFilterValues(filterStatus, dataSource),
getSliderFilterValues(filterStatus, dataSource)
)
for (source in sources) {
addSource(source) {
value = sources.mapNotNull { it.value }.flatten()
val values = sources.map { it.value }
if (values.all { it != null }) {
value = values.filterNotNull().flatten()
}
}
}
}
@@ -65,10 +86,10 @@ abstract class FilterValueDao {
}
@Transaction
open suspend fun deleteFilterValuesForProfile(profile: Long) {
deleteBooleanFilterValuesForProfile(profile)
deleteMultipleChoiceFilterValuesForProfile(profile)
deleteSliderFilterValuesForProfile(profile)
open suspend fun deleteFilterValuesForProfile(profile: Long, dataSource: String) {
deleteBooleanFilterValuesForProfile(profile, dataSource)
deleteMultipleChoiceFilterValuesForProfile(profile, dataSource)
deleteSliderFilterValuesForProfile(profile, dataSource)
}
}

View File

@@ -0,0 +1,119 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@Entity
data class GENetwork(@PrimaryKey val name: String)
@Entity
data class GEPlug(@PrimaryKey val name: String)
@Dao
abstract class GEReferenceDataDao {
// NETWORKS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg networks: GENetwork)
@Query("DELETE FROM genetwork")
abstract fun deleteAllNetworks()
@Transaction
open suspend fun updateNetworks(networks: List<GENetwork>) {
deleteAllNetworks()
for (network in networks) {
insert(network)
}
}
@Query("SELECT * FROM genetwork")
abstract fun getAllNetworks(): LiveData<List<GENetwork>>
// PLUGS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg plugs: GEPlug)
@Query("DELETE FROM geplug")
abstract fun deleteAllPlugs()
@Transaction
open suspend fun updatePlugs(plugs: List<GEPlug>) {
deleteAllPlugs()
for (plug in plugs) {
insert(plug)
}
}
@Query("SELECT * FROM geplug")
abstract fun getAllPlugs(): LiveData<List<GEPlug>>
// CHARGE CARDS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg chargeCards: GEChargeCard)
@Query("DELETE FROM gechargecard")
abstract fun deleteAllChargeCards()
@Transaction
open suspend fun updateChargeCards(chargeCards: List<GEChargeCard>) {
deleteAllChargeCards()
for (chargeCard in chargeCards) {
insert(chargeCard)
}
}
@Query("SELECT * FROM gechargecard")
abstract fun getAllChargeCards(): LiveData<List<GEChargeCard>>
}
class GEReferenceDataRepository(
private val api: GoingElectricApiWrapper, private val scope: CoroutineScope,
private val dao: GEReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<GEReferenceData> {
scope.launch {
updateData()
}
val plugs = dao.getAllPlugs()
val networks = dao.getAllNetworks()
val chargeCards = dao.getAllChargeCards()
return MediatorLiveData<GEReferenceData>().apply {
listOf(chargeCards, networks, plugs).map { source ->
addSource(source) { _ ->
val p = plugs.value ?: return@addSource
val n = networks.value ?: return@addSource
val cc = chargeCards.value ?: return@addSource
value = GEReferenceData(p.map { it.name }, n.map { it.name }, cc)
}
}
}
}
private suspend fun updateData() {
if (Duration.between(
prefs.lastGeReferenceDataUpdate,
Instant.now()
) < Duration.ofDays(1)
) return
val response = api.getReferenceData()
if (response.status == Status.ERROR) return // ignore and retry next time
val data = response.data!!
dao.updateNetworks(data.networks.map { GENetwork(it) })
dao.updatePlugs(data.plugs.map { GEPlug(it) })
dao.updateChargeCards(data.chargecards)
prefs.lastGeReferenceDataUpdate = Instant.now()
}
}

View File

@@ -1,56 +0,0 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.io.IOException
import java.time.Duration
import java.time.Instant
@Entity
data class Network(@PrimaryKey val name: String)
@Dao
interface NetworkDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg networks: Network)
@Delete
suspend fun delete(vararg networks: Network)
@Query("SELECT * FROM network")
fun getAllNetworks(): LiveData<List<Network>>
}
class NetworkRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: NetworkDao, private val prefs: PreferenceDataSource
) {
fun getNetworks(): LiveData<List<Network>> {
scope.launch {
updateNetworks()
}
return dao.getAllNetworks()
}
private suspend fun updateNetworks() {
if (Duration.between(prefs.lastNetworkUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getNetworks()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Network(name))
}
prefs.lastNetworkUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -0,0 +1,112 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.openchargemap.*
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@Dao
abstract class OCMReferenceDataDao {
// CONNECTION TYPES
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg connectionTypes: OCMConnectionType)
@Query("DELETE FROM ocmconnectiontype")
abstract fun deleteAllConnectionTypes()
@Transaction
open suspend fun updateConnectionTypes(connectionTypes: List<OCMConnectionType>) {
deleteAllConnectionTypes()
for (connectionType in connectionTypes) {
insert(connectionType)
}
}
@Query("SELECT * FROM ocmconnectiontype")
abstract fun getAllConnectionTypes(): LiveData<List<OCMConnectionType>>
// COUNTRIES
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg countries: OCMCountry)
@Query("DELETE FROM ocmcountry")
abstract fun deleteAllCountries()
@Transaction
open suspend fun updateCountries(countries: List<OCMCountry>) {
deleteAllCountries()
for (country in countries) {
insert(country)
}
}
@Query("SELECT * FROM ocmcountry")
abstract fun getAllCountries(): LiveData<List<OCMCountry>>
// OPERATORS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg operators: OCMOperator)
@Query("DELETE FROM ocmoperator")
abstract fun deleteAllOperators()
@Transaction
open suspend fun updateOperators(operators: List<OCMOperator>) {
deleteAllOperators()
for (operator in operators) {
insert(operator)
}
}
@Query("SELECT * FROM ocmoperator")
abstract fun getAllOperators(): LiveData<List<OCMOperator>>
}
class OCMReferenceDataRepository(
private val api: OpenChargeMapApiWrapper, private val scope: CoroutineScope,
private val dao: OCMReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<OCMReferenceData> {
scope.launch {
updateData()
}
val connectionTypes = dao.getAllConnectionTypes()
val countries = dao.getAllCountries()
val operators = dao.getAllOperators()
return MediatorLiveData<OCMReferenceData>().apply {
listOf(countries, connectionTypes, operators).map { source ->
addSource(source) { _ ->
val ct = connectionTypes.value
val c = countries.value
val o = operators.value
if (ct.isNullOrEmpty() || c.isNullOrEmpty() || o.isNullOrEmpty()) return@addSource
value = OCMReferenceData(ct, c, o)
}
}
}
}
private suspend fun updateData() {
if (Duration.between(
prefs.lastOcmReferenceDataUpdate,
Instant.now()
) < Duration.ofDays(1)
) return
val response = api.getReferenceData()
if (response.status == Status.ERROR) return // ignore and retry next time
val data = response.data!!
dao.updateConnectionTypes(data.connectionTypes)
dao.updateCountries(data.countries)
dao.updateOperators(data.operators)
prefs.lastOcmReferenceDataUpdate = Instant.now()
}
}

View File

@@ -1,56 +0,0 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.io.IOException
import java.time.Duration
import java.time.Instant
@Entity
data class Plug(@PrimaryKey val name: String)
@Dao
interface PlugDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg plugs: Plug)
@Delete
suspend fun delete(vararg plugs: Plug)
@Query("SELECT * FROM plug")
fun getAllPlugs(): LiveData<List<Plug>>
}
class PlugRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: PlugDao, private val prefs: PreferenceDataSource
) {
fun getPlugs(): LiveData<List<Plug>> {
scope.launch {
updatePlugs()
}
return dao.getAllPlugs()
}
private suspend fun updatePlugs() {
if (Duration.between(prefs.lastPlugUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getPlugs()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Plug(name))
}
prefs.lastPlugUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -4,35 +4,41 @@ import android.content.Context
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import java.time.Instant
class PreferenceDataSource(val context: Context) {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
var dataSource: String
get() = sp.getString("data_source", "goingelectric")!!
set(value) {
sp.edit().putString("data_source", value).apply()
}
var dataSourceSet: Boolean
get() = sp.getBoolean("data_source_set", false)
set(value) {
sp.edit().putBoolean("data_source_set", value).apply()
}
var navigateUseMaps: Boolean
get() = sp.getBoolean("navigate_use_maps", true)
set(value) {
sp.edit().putBoolean("navigate_use_maps", value).apply()
}
var lastPlugUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_plug_update", 0L))
var lastGeReferenceDataUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_ge_reference_data_update", 0L))
set(value) {
sp.edit().putLong("last_plug_update", value.toEpochMilli()).apply()
sp.edit().putLong("last_ge_reference_data_update", value.toEpochMilli()).apply()
}
var lastNetworkUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_network_update", 0L))
var lastOcmReferenceDataUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_ocm_reference_data_update", 0L))
set(value) {
sp.edit().putLong("last_network_update", value.toEpochMilli()).apply()
}
var lastChargeCardUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_chargecard_update", 0L))
set(value) {
sp.edit().putLong("last_chargecard_update", value.toEpochMilli()).apply()
sp.edit().putLong("last_ocm_reference_data_update", value.toEpochMilli()).apply()
}
/**
@@ -98,17 +104,21 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
}
var chargepriceMyVehicle: String?
get() = sp.getString("chargeprice_my_vehicle", null)
var chargepriceMyVehicles: Set<String>
get() = try {
sp.getStringSet("chargeprice_my_vehicle", emptySet())!!
} catch (e: ClassCastException) {
// backwards compatibility
sp.getString("chargeprice_my_vehicle", null)?.let { setOf(it) } ?: emptySet()
}
set(value) {
sp.edit().putString("chargeprice_my_vehicle", value).apply()
sp.edit().putStringSet("chargeprice_my_vehicle", value).apply()
}
var chargepriceMyVehicleDcChargeports: List<String>?
get() = sp.getString("chargeprice_my_vehicle_dc_chargeports", null)?.split(",")
var chargepriceLastSelectedVehicle: String?
get() = sp.getString("chargeprice_last_vehicle", null)
set(value) {
sp.edit().putString("chargeprice_my_vehicle_dc_chargeports", value?.joinToString(","))
.apply()
sp.edit().putString("chargeprice_last_vehicle", value).apply()
}
var chargepriceMyTariffs: Set<String>?

View File

@@ -3,14 +3,24 @@ package net.vonforst.evmap.storage
import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import net.vonforst.evmap.api.goingelectric.ChargeCardId
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import net.vonforst.evmap.api.goingelectric.GEChargerPhotoAdapter
import net.vonforst.evmap.api.openchargemap.OCMChargerPhotoAdapter
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import java.time.Instant
import java.time.LocalTime
class Converters {
val moshi = Moshi.Builder().build()
val moshi = Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(ChargerPhoto::class.java, "type")
.withSubtype(GEChargerPhotoAdapter::class.java, "goingelectric")
.withSubtype(OCMChargerPhotoAdapter::class.java, "openchargemap")
.withDefaultValue(null)
)
.build()
private val chargepointListAdapter by lazy {
val type = Types.newParameterizedType(List::class.java, Chargepoint::class.java)
moshi.adapter<List<Chargepoint>>(type)
@@ -27,6 +37,10 @@ class Converters {
val type = Types.newParameterizedType(Set::class.java, String::class.java)
moshi.adapter<Set<String>>(type)
}
private val stringListAdapter by lazy {
val type = Types.newParameterizedType(List::class.java, String::class.java)
moshi.adapter<List<String>>(type)
}
@TypeConverter
fun fromChargepointList(value: List<Chargepoint>?): String {
@@ -45,7 +59,7 @@ class Converters {
@TypeConverter
fun toChargerPhotoList(value: String): List<ChargerPhoto>? {
return chargerPhotoListAdapter.fromJson(value)
return chargerPhotoListAdapter.fromJson(value)?.filterNotNull()
}
@TypeConverter
@@ -91,4 +105,14 @@ class Converters {
fun toStringSet(value: String): Set<String>? {
return stringSetAdapter.fromJson(value)
}
@TypeConverter
fun fromStringList(value: List<String>?): String {
return stringListAdapter.toJson(value)
}
@TypeConverter
fun toStringList(value: String): List<String>? {
return stringListAdapter.fromJson(value)
}
}

View File

@@ -22,6 +22,8 @@ import com.google.android.material.slider.RangeSlider
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -265,6 +267,13 @@ fun currency(currency: String): String {
}
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt();
val min = ceil(value.toDouble() % 60).toInt();
return if (h == 0 && min > 0) "$min min";
else "%d:%02d h".format(h, min);
}
@InverseBindingAdapter(attribute = "app:values")
fun getRangeSliderValue(slider: RangeSlider) = slider.values

View File

@@ -3,10 +3,10 @@ package net.vonforst.evmap.ui;
import com.car2go.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.Coordinate
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Coordinate
fun cluster(

View File

@@ -0,0 +1,18 @@
package net.vonforst.evmap.ui
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.ListPreference
import net.vonforst.evmap.fragment.DataSourceSelectDialog
class DataSourceSelectDialogPreference(ctx: Context, attrs: AttributeSet) :
ListPreference(ctx, attrs) {
override fun onClick() {
val dialog = DataSourceSelectDialog.getInstance(true)
dialog.okListener = { selected ->
value = selected
}
dialog.show((context as AppCompatActivity).supportFragmentManager, null)
}
}

View File

@@ -7,7 +7,7 @@ import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.car2go.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.model.ChargeLocation
import kotlin.math.max
fun getMarkerTint(

View File

@@ -4,17 +4,44 @@ import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.MultiSelectListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.MultiSelectDialog
class MultiSelectDialogPreference(ctx: Context, attrs: AttributeSet) :
MultiSelectListPreference(ctx, attrs) {
val showAllButton: Boolean
val defaultToAll: Boolean
init {
val a = ctx.obtainStyledAttributes(attrs, R.styleable.MultiSelectDialogPreference)
showAllButton = a.getBoolean(R.styleable.MultiSelectDialogPreference_showAllButton, true)
defaultToAll = a.getBoolean(R.styleable.MultiSelectDialogPreference_defaultToAll, true)
a.recycle()
}
override fun onSetInitialValue(defaultValue: Any?) {
try {
super.onSetInitialValue(defaultValue)
} catch (e: ClassCastException) {
// backwards compatibility when changing a ListPreference into a MultiSelectListPreference
val value =
getPersistedString(null)?.let { setOf(it) } ?: (defaultValue as Set<String>?)
sharedPreferences.edit()
.remove(key)
.putStringSet(key, value)
.apply()
super.onSetInitialValue(defaultValue)
}
}
override fun onClick() {
val dialog =
MultiSelectDialog.getInstance(
title.toString(),
entryValues.map { it.toString() }.zip(entries.map { it.toString() }).toMap(),
if (all) entryValues.map { it.toString() }.toSet() else values,
emptySet()
emptySet(),
showAllButton
)
dialog.okListener = { selected ->
all = selected == entryValues.toSet()
@@ -24,7 +51,7 @@ class MultiSelectDialogPreference(ctx: Context, attrs: AttributeSet) :
}
var all: Boolean
get() = sharedPreferences.getBoolean(key + "_all", true)
get() = sharedPreferences.getBoolean(key + "_all", defaultToAll)
set(value) {
sharedPreferences.edit().putBoolean(key + "_all", value).apply()
}

View File

@@ -1,6 +1,9 @@
package net.vonforst.evmap.utils
import android.content.Intent
import android.location.Location
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlin.math.*
/**
@@ -12,6 +15,12 @@ fun Location.plusMeters(dx: Double, dy: Double): Pair<Double, Double> {
return Pair(lat, lon)
}
fun LatLng.plusMeters(dx: Double, dy: Double): LatLng {
val lat = this.latitude + (180 / Math.PI) * (dx / 6378137.0)
val lon = this.longitude + (180 / Math.PI) * (dy / 6378137.0) / cos(Math.toRadians(lat))
return LatLng(lat, lon)
}
const val earthRadiusM = 6378137.0
/**
@@ -31,4 +40,38 @@ fun distanceBetween(
val a = sin(dLat / 2).pow(2.0) + sin(dLon / 2).pow(2.0) * cos(originLat) * cos(destinationLat)
val c = 2 * asin(sqrt(a))
return earthRadiusM * c
}
}
fun getLocationFromIntent(intent: Intent): List<Double>? {
val pos = intent.data?.schemeSpecificPart?.split("?")?.get(0)
var coords = stringToCoords(pos)
if (coords != null) {
return coords
}
val query = intent.data?.query?.split("=")?.get(1)
coords = stringToCoords(query)
if (coords != null) {
return coords
} else {
return null
}
}
internal fun stringToCoords(s: String?): List<Double>? {
if (s == null) return null
val coords = s.split(",").mapNotNull { it.toDoubleOrNull() }
return if (coords.size == 2 && !(coords[0] == 0.0 && coords[1] == 0.0)) {
coords
} else {
null
}
}
fun boundingBox(pos: LatLng, sizeMeters: Double): LatLngBounds {
return LatLngBounds(
pos.plusMeters(-sizeMeters, -sizeMeters),
pos.plusMeters(sizeMeters, sizeMeters)
)
}

View File

@@ -6,9 +6,11 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import moe.banana.jsonapi2.HasOne
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
import java.util.*
@@ -21,34 +23,54 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
MutableLiveData<ChargeLocation>()
}
val dataSource: MutableLiveData<String> by lazy {
MutableLiveData<String>()
}
val chargepoint: MutableLiveData<Chargepoint> by lazy {
MutableLiveData<Chargepoint>()
}
val vehicle: LiveData<ChargepriceCar> by lazy {
MutableLiveData<ChargepriceCar>().apply {
value = prefs.chargepriceMyVehicle?.let { ChargepriceCar().apply { id = it } }
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
if (prefs.chargepriceMyVehicles.isEmpty()) {
value = Resource.success(emptyList())
} else {
value = Resource.loading(null)
loadVehicles()
}
observeForever {
vehicle.value = it.data?.firstOrNull()
}
}
}
val vehicle: MutableLiveData<ChargepriceCar> by lazy {
MutableLiveData<ChargepriceCar>()
}
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS,
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
val vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
MutableLiveData<List<String>>().apply {
value = prefs.chargepriceMyVehicleDcChargeports?.map {
plugMapping.get(it)
}?.filterNotNull()?.plus(acConnectors)
MediatorLiveData<List<String>>().apply {
addSource(vehicle) {
value = it?.dcChargePorts?.map {
plugMapping[it]
}?.filterNotNull()?.plus(acConnectors)
}
}
}
@@ -59,7 +81,8 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
addSource(it) {
val charger = charger.value ?: return@addSource
val connectors = vehicleCompatibleConnectors.value ?: return@addSource
value = !charger.chargepoints.map { it.type }.any { it in connectors }
value = !charger.chargepoints.flatMap { equivalentPlugTypes(it.type) }
.any { it in connectors }
}
}
}
@@ -69,7 +92,14 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
MutableLiveData<List<Float>>().apply {
value = prefs.chargepriceBatteryRange
observeForever {
prefs.chargepriceBatteryRange = it
if (it[0] == it[1]) {
value = if (it[0] < 1.0) {
listOf(it[0], it[1] + 1)
} else {
listOf(it[0] - 1, it[1])
}
}
prefs.chargepriceBatteryRange = value!!
}
}
}
@@ -84,7 +114,7 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
value = Resource.loading(null)
listOf(
charger,
vehicle,
dataSource,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors
@@ -118,7 +148,9 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
val myTariffs = prefs.chargepriceMyTariffs
value = Resource.success(cps.data!!.map { cp ->
val filteredPrices =
cp.chargepointPrices.filter { it.plug == chargepoint.type && it.power == chargepoint.power }
cp.chargepointPrices.filter {
it.plug == getChargepricePlugType(chargepoint) && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
@@ -139,6 +171,12 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
}
}
private fun getChargepricePlugType(chargepoint: Chargepoint): String {
val index = charger.value!!.chargepointsMerged.indexOf(chargepoint)
val type = charger.value!!.chargepriceData!!.plugTypes?.get(index) ?: chargepoint.type
return type
}
val myTariffs: LiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyTariffs
@@ -164,7 +202,11 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
value = Resource.loading(null)
} else {
value =
Resource.success(cpMeta.data!!.chargePoints.filter { it.plug == chargepoint.type && it.power == chargepoint.power }[0])
Resource.success(cpMeta.data!!.chargePoints.filter {
it.plug == getChargepricePlugType(
chargepoint
) && it.power == chargepoint.power
}[0])
}
}
}
@@ -174,21 +216,23 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
private var loadPricesJob: Job? = null
fun loadPrices() {
chargePrices.value = Resource.loading(null)
val geCharger = charger.value
chargePriceMeta.value = Resource.loading(null)
val charger = charger.value
val car = vehicle.value
val compatibleConnectors = vehicleCompatibleConnectors.value
if (geCharger == null || car == null || compatibleConnectors == null) {
val dataSource = dataSource.value
if (charger == null || car == null || compatibleConnectors == null || dataSource == null) {
chargePrices.value = Resource.error(null, null)
return
}
val cpStation = ChargepriceStation.fromGoingelectric(geCharger, compatibleConnectors)
val cpStation = ChargepriceStation.fromEvmap(charger, compatibleConnectors)
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
try {
val result = api.getChargePrices(ChargepriceRequest().apply {
dataAdapter = "going_electric"
dataAdapter = dataSource
station = cpStation
vehicle = HasOne(car)
options = ChargepriceOptions(
@@ -205,6 +249,22 @@ class ChargepriceViewModel(application: Application, chargepriceApiKey: String)
} catch (e: IOException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
} catch (e: HttpException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
}
}
}
private fun loadVehicles() {
viewModelScope.launch {
try {
val result = api.getVehicles()
vehicles.value = Resource.success(result.filter {
it.id in prefs.chargepriceMyVehicles
})
} catch (e: IOException) {
vehicles.value = Resource.error(e.message, null)
}
}
}

View File

@@ -0,0 +1,81 @@
package net.vonforst.evmap.viewmodel
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.CoroutineScope
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.*
import kotlin.reflect.full.cast
fun ChargepointApi<ReferenceData>.getReferenceData(
scope: CoroutineScope,
ctx: Context
): LiveData<out ReferenceData> {
val db = AppDatabase.getInstance(ctx)
val prefs = PreferenceDataSource(ctx)
return when (this) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
this,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
this,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}
}
fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
val values = filterValues.value ?: return@addSource
value = f.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
}
}
}
fun ChargepointApi<ReferenceData>.getFilters(
referenceData: LiveData<out ReferenceData>,
stringProvider: StringProvider
) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = getFilters(data, stringProvider)
}
}
fun FilterValueDao.getFilterValues(filterStatus: LiveData<Long>, dataSource: String) =
MediatorLiveData<List<FilterValue>>().apply {
var source: LiveData<List<FilterValue>>? = null
addSource(filterStatus) { status ->
source?.let { removeSource(it) }
source = getFilterValues(status, dataSource)
addSource(source!!) { result ->
value = result
}
}
}

View File

@@ -10,14 +10,12 @@ import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.utils.distanceBetween
class FavoritesViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
val favorites: LiveData<List<ChargeLocation>> by lazy {

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
@@ -14,12 +15,12 @@ class FilterProfilesViewModel(application: Application) : AndroidViewModel(appli
private var prefs = PreferenceDataSource(application)
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles()
db.filterProfileDao().getProfiles(prefs.dataSource)
}
fun delete(itemId: Long) {
viewModelScope.launch {
val profile = db.filterProfileDao().getProfileById(itemId)
val profile = db.filterProfileDao().getProfileById(itemId, prefs.dataSource)
profile?.let { db.filterProfileDao().delete(it) }
if (prefs.filterStatus == profile?.id) {
prefs.filterStatus = FILTERS_DISABLED

View File

@@ -1,164 +1,31 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.databinding.BaseObservable
import androidx.lifecycle.*
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import kotlinx.coroutines.launch
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.storage.*
import kotlin.math.abs
import kotlin.reflect.KClass
import kotlin.reflect.full.cast
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
internal fun mapPower(i: Int) = powerSteps[i]
internal fun mapPowerInverse(power: Int) = powerSteps
.mapIndexed { index, v -> abs(v - power) to index }
.minByOrNull { it.first }?.second ?: 0
internal fun getFilters(
application: Application,
plugs: LiveData<List<Plug>>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>
): LiveData<List<Filter<FilterValue>>> {
return MediatorLiveData<List<Filter<FilterValue>>>().apply {
listOf(plugs, networks, chargeCards).forEach { source ->
addSource(source) { _ ->
buildFilters(plugs, networks, chargeCards, application)
}
}
}
}
private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
plugs: LiveData<List<Plug>>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>,
application: Application
) {
val plugMap = plugs.value?.map { plug ->
plug.name to nameForPlugType(application, plug.name)
}?.toMap() ?: return
val networkMap = networks.value?.map { it.name to it.name }?.toMap() ?: return
val chargecardMap = chargeCards.value?.map { it.id.toString() to it.name }?.toMap() ?: return
val categoryMap = mapOf(
"Autohaus" to application.getString(R.string.category_car_dealership),
"Autobahnraststätte" to application.getString(R.string.category_service_on_motorway),
"Autohof" to application.getString(R.string.category_service_off_motorway),
"Bahnhof" to application.getString(R.string.category_railway_station),
"Behörde" to application.getString(R.string.category_public_authorities),
"Campingplatz" to application.getString(R.string.category_camping),
"Einkaufszentrum" to application.getString(R.string.category_shopping_mall),
"Ferienwohnung" to application.getString(R.string.category_holiday_home),
"Flughafen" to application.getString(R.string.category_airport),
"Freizeitpark" to application.getString(R.string.category_amusement_park),
"Hotel" to application.getString(R.string.category_hotel),
"Kino" to application.getString(R.string.category_cinema),
"Kirche" to application.getString(R.string.category_church),
"Krankenhaus" to application.getString(R.string.category_hospital),
"Museum" to application.getString(R.string.category_museum),
"Parkhaus" to application.getString(R.string.category_parking_multi),
"Parkplatz" to application.getString(R.string.category_parking),
"Privater Ladepunkt" to application.getString(R.string.category_private_charger),
"Rastplatz" to application.getString(R.string.category_rest_area),
"Restaurant" to application.getString(R.string.category_restaurant),
"Schwimmbad" to application.getString(R.string.category_swimming_pool),
"Supermarkt" to application.getString(R.string.category_supermarket),
"Tankstelle" to application.getString(R.string.category_petrol_station),
"Tiefgarage" to application.getString(R.string.category_parking_underground),
"Tierpark" to application.getString(R.string.category_zoo),
"Wohnmobilstellplatz" to application.getString(R.string.category_caravan_site)
)
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(application.getString(R.string.filter_open_247), "open_247"),
SliderFilter(
application.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
application.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO),
manyChoices = true
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
MultipleChoiceFilter(
application.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
application.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(application.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
application.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
),
BooleanFilter(application.getString(R.string.filter_exclude_faults), "exclude_faults")
)
}
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
): MediatorLiveData<FilterValues> =
MediatorLiveData<FilterValues>().apply {
listOf(filters, filterValues).forEach {
addSource(it) {
val f = filters.value ?: return@addSource
val values = filterValues.value ?: return@addSource
value = f.map { filter ->
val value =
values.find { it.key == filter.key } ?: filter.defaultValue()
FilterWithValue(filter, filter.valueClass.cast(value))
}
}
}
}
class FilterViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey, context = application)
class FilterViewModel(application: Application) : AndroidViewModel(application) {
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
}
private val chargeCards: LiveData<List<ChargeCard>> by lazy {
ChargeCardRepository(api, viewModelScope, db.chargeCardDao(), prefs).getChargeCards()
}
private val filters: LiveData<List<Filter<FilterValue>>> by lazy {
getFilters(application, plugs, networks, chargeCards)
private val referenceData = api.getReferenceData(viewModelScope, application)
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = api.getFilters(data, application.stringProvider())
}
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
db.filterValueDao().getFilterValues(FILTERS_CUSTOM)
db.filterValueDao().getFilterValues(FILTERS_CUSTOM, prefs.dataSource)
}
val filtersWithValue: LiveData<FilterValues> by lazy {
@@ -177,7 +44,7 @@ class FilterViewModel(application: Application, geApiKey: String) :
when (id) {
FILTERS_CUSTOM, FILTERS_DISABLED -> value = null
else -> viewModelScope.launch {
value = db.filterProfileDao().getProfileById(id)
value = db.filterProfileDao().getProfileById(id, prefs.dataSource)
}
}
}
@@ -185,10 +52,13 @@ class FilterViewModel(application: Application, geApiKey: String) :
}
suspend fun saveFilterValues() {
filtersWithValue.value?.forEach {
filtersWithValue.value?.map {
val value = it.value
value.profile = FILTERS_CUSTOM
db.filterValueDao().insert(value)
value.dataSource = prefs.dataSource
value
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
// set selected profile
@@ -197,141 +67,23 @@ class FilterViewModel(application: Application, geApiKey: String) :
suspend fun saveAsProfile(name: String) {
// get or create profile
var profileId = db.filterProfileDao().getProfileByName(name)?.id
var profileId = db.filterProfileDao().getProfileByName(name, prefs.dataSource)?.id
if (profileId == null) {
profileId = db.filterProfileDao().insert(FilterProfile(name))
profileId = db.filterProfileDao().getNewId(prefs.dataSource)
db.filterProfileDao().insert(FilterProfile(name, prefs.dataSource, profileId))
}
// save filter values
filtersWithValue.value?.forEach {
filtersWithValue.value?.map {
val value = it.value
value.profile = profileId
db.filterValueDao().insert(value)
value.dataSource = prefs.dataSource
value
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
// set selected profile
prefs.filterStatus = profileId
}
}
sealed class Filter<out T : FilterValue> : Equatable {
abstract val name: String
abstract val key: String
abstract val valueClass: KClass<out T>
abstract fun defaultValue(): T
}
data class BooleanFilter(override val name: String, override val key: String) :
Filter<BooleanFilterValue>() {
override val valueClass: KClass<BooleanFilterValue> = BooleanFilterValue::class
override fun defaultValue() = BooleanFilterValue(key, false)
}
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>,
val commonChoices: Set<String>? = null,
val manyChoices: Boolean = false
) : Filter<MultipleChoiceFilterValue>() {
override val valueClass: KClass<MultipleChoiceFilterValue> = MultipleChoiceFilterValue::class
override fun defaultValue() = MultipleChoiceFilterValue(key, mutableSetOf(), true)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int,
val min: Int = 0,
val mapping: ((Int) -> Int) = { it },
val inverseMapping: ((Int) -> Int) = { it },
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var profile: Long = FILTERS_CUSTOM
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class BooleanFilterValue(
override val key: String,
var value: Boolean
) : FilterValue()
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class MultipleChoiceFilterValue(
override val key: String,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
override fun equals(other: Any?): Boolean {
if (other == null || other !is MultipleChoiceFilterValue) return false
if (key != other.key) return false
return if (all) {
other.all
} else {
!other.all && values == other.values
}
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + all.hashCode()
result = 31 * result + if (all) 0 else values.hashCode()
return result
}
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class SliderFilterValue(
override val key: String,
var value: Int
) : FilterValue()
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }!!.value as BooleanFilterValue).value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }!!.value as SliderFilterValue).value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }!!.filter as MultipleChoiceFilter
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L
}

View File

@@ -5,16 +5,23 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.api.createApi
import net.vonforst.evmap.api.goingelectric.GEChargepoint
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.openchargemap.OCMConnection
import net.vonforst.evmap.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
@@ -32,10 +39,15 @@ internal fun getClusterDistance(zoom: Float): Int? {
}
}
class MapViewModel(application: Application, geApiKey: String) : AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey, context = application)
class MapViewModel(application: Application) : AndroidViewModel(application) {
val apiType: Class<ChargepointApi<ReferenceData>>
get() = api.javaClass
val apiName: String
get() = api.getName()
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private var api: ChargepointApi<ReferenceData> = createApi(prefs.dataSource, application)
val bottomSheetState: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
@@ -44,44 +56,39 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val mapPosition: MutableLiveData<MapPosition> by lazy {
MutableLiveData<MapPosition>()
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
MediatorLiveData<List<FilterValue>>().apply {
var source: LiveData<List<FilterValue>>? = null
addSource(filterStatus) { status ->
source?.let { removeSource(it) }
source = db.filterValueDao().getFilterValues(status)
addSource(source!!) { result ->
value = result
}
val filterStatus: MutableLiveData<Long> by lazy {
MutableLiveData<Long>().apply {
value = prefs.filterStatus
observeForever {
prefs.filterStatus = it
if (it != FILTERS_DISABLED) prefs.lastFilterProfile = it
}
}
}
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
}
private val chargeCards: LiveData<List<ChargeCard>> by lazy {
ChargeCardRepository(api, viewModelScope, db.chargeCardDao(), prefs).getChargeCards()
}
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filterValues: LiveData<List<FilterValue>> =
db.filterValueDao().getFilterValues(filterStatus, prefs.dataSource)
private val referenceData = api.getReferenceData(viewModelScope, application)
private val filters = api.getFilters(referenceData, application.stringProvider())
private val filtersWithValue: LiveData<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
}
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
db.filterProfileDao().getProfiles()
db.filterProfileDao().getProfiles(prefs.dataSource)
}
val chargeCardMap: LiveData<Map<Long, ChargeCard>> by lazy {
MediatorLiveData<Map<Long, ChargeCard>>().apply {
value = null
addSource(chargeCards) {
value = chargeCards.value?.map {
it.id to it
}?.toMap()
addSource(referenceData) { data ->
value = if (data is GEReferenceData) {
data.chargecards.map {
it.id to it.convert()
}.toMap()
} else {
null
}
}
}
}
@@ -91,7 +98,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
value = 0
addSource(filtersWithValue) { filtersWithValue ->
value = filtersWithValue.count {
it.filter.defaultValue() != it.value
!it.value.hasSameValueAs(it.filter.defaultValue())
}
}
}
@@ -100,7 +107,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
MediatorLiveData<Resource<List<ChargepointListItem>>>()
.apply {
value = Resource.loading(emptyList())
listOf(mapPosition, filtersWithValue).forEach {
listOf(mapPosition, filtersWithValue, referenceData).forEach {
addSource(it) {
reloadChargepoints()
}
@@ -110,6 +117,9 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val filteredConnectors: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>()
}
val filteredMinPower: MutableLiveData<Int> by lazy {
MutableLiveData<Int>()
}
val filteredChargeCards: MutableLiveData<Set<Long>> by lazy {
MutableLiveData<Set<Long>>()
}
@@ -119,11 +129,15 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
val chargerDetails: MediatorLiveData<Resource<ChargeLocation>> by lazy {
MediatorLiveData<Resource<ChargeLocation>>().apply {
addSource(chargerSparse) { charger ->
if (charger != null) {
loadChargerDetails(charger)
} else {
value = null
listOf(chargerSparse, referenceData).forEach {
addSource(it) { _ ->
val charger = chargerSparse.value
val refData = referenceData.value
if (charger != null && refData != null) {
loadChargerDetails(charger, refData)
} else {
value = null
}
}
}
}
@@ -180,13 +194,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val av = availability.value
val filters = filtersWithValue.value
if (av?.status == Status.SUCCESS && filters != null) {
value = Resource.success(av.data!!.applyFilters(filters))
value = Resource.success(
av.data!!.applyFilters(
filteredConnectors.value,
filteredMinPower.value
)
)
} else {
value = av
}
}
addSource(availability, callback)
addSource(filtersWithValue, callback)
addSource(filteredConnectors, callback)
addSource(filteredMinPower, callback)
}
}
val myLocationEnabled: MutableLiveData<Boolean> by lazy {
@@ -224,16 +244,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
val filterStatus: MutableLiveData<Long> by lazy {
MutableLiveData<Long>().apply {
value = prefs.filterStatus
observeForever {
prefs.filterStatus = it
if (it != FILTERS_DISABLED) prefs.lastFilterProfile = it
}
}
}
fun reloadPrefs() {
filterStatus.value = prefs.filterStatus
}
@@ -249,10 +259,12 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
suspend fun copyFiltersToCustom() {
if (filterStatus.value == FILTERS_CUSTOM) return
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM)
filterValues.value?.forEach {
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
filterValues.value?.map {
it.profile = FILTERS_CUSTOM
db.filterValueDao().insert(it)
it
}?.let {
db.filterValueDao().insert(*it.toTypedArray())
}
}
@@ -275,165 +287,69 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
fun reloadChargepoints() {
val pos = mapPosition.value ?: return
val filters = filtersWithValue.value ?: return
loadChargepoints(pos, filters)
val referenceData = referenceData.value ?: return
chargepointLoader(Triple(pos, filters, referenceData))
}
private var chargepointLoader =
throttleLatest(500L, viewModelScope) { data: Pair<MapPosition, FilterValues> ->
throttleLatest(
500L,
viewModelScope
) { data: Triple<MapPosition, FilterValues, ReferenceData> ->
chargepoints.value = Resource.loading(chargepoints.value?.data)
filteredConnectors.value = null
filteredMinPower.value = null
filteredChargeCards.value = null
val mapPosition = data.first
val filters = data.second
val result = getChargepointsWithFilters(mapPosition.bounds, mapPosition.zoom, filters)
filteredConnectors.value = result.second
filteredChargeCards.value = result.third
chargepoints.value = result.first
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: FilterValues
) {
chargepointLoader(Pair(mapPosition, filters))
}
private suspend fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
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")
val connectorsVal = filters.getMultipleChoiceValue("connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Triple(Resource.success(emptyList()), null, null)
}
val connectors = formatMultipleChoice(connectorsVal)
val filteredConnectors = if (connectorsVal.all) null else connectorsVal.values
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Triple(Resource.success(emptyList()), filteredConnectors, null)
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val filteredChargeCards =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val networksVal = filters.getMultipleChoiceValue("networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters.getMultipleChoiceValue("categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
var startkey: Int? = null
val data = mutableListOf<ChargepointListItem>()
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,
minPower = minPower,
freeparking = freeparking,
open247 = open247,
barrierfree = barrierfree,
excludeFaults = excludeFaults,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Triple(
Resource.error(response.message(), chargepoints.value?.data),
null,
null
)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Triple(
Resource.error(e.message, chargepoints.value?.data),
filteredConnectors,
filteredChargeCards
)
val api = api
val refData = data.third
var result = api.getChargepoints(refData, mapPosition.bounds, mapPosition.zoom, filters)
if (result.status == Status.ERROR && result.data == null) {
// keep old results if new data could not be loaded
result = Resource.error(result.message, chargepoints.value?.data)
}
} while (startkey != null && startkey < 10000)
chargepoints.value = result
var result = data.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is ChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
} else {
true
if (api is GoingElectricApiWrapper) {
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")!!
filteredChargeCards.value =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }
.toSet()
val connectorsVal = filters.getMultipleChoiceValue("connectors")!!
filteredConnectors.value =
if (connectorsVal.all) null else connectorsVal.values.map {
GEChargepoint.convertTypeFromGE(it)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
} else if (api is OpenChargeMapApiWrapper) {
val connectorsVal = filters.getMultipleChoiceValue("connectors")!!
filteredConnectors.value =
if (connectorsVal.all) null else connectorsVal.values.map {
OCMConnection.convertConnectionTypeFromOCM(
it.toLong(),
refData as OCMReferenceData
)
}.toSet()
filteredMinPower.value = filters.getSliderValue("minPower")
}
}
if (!geClusteringAvailable && useClustering) {
// apply local clustering if server side clustering is not available
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return Triple(Resource.success(result), filteredConnectors, filteredChargeCards)
}
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)
}
private fun loadChargerDetails(charger: ChargeLocation) {
private var chargerLoadingTask: Job? = null
private fun loadChargerDetails(charger: ChargeLocation, referenceData: ReferenceData) {
chargerDetails.value = Resource.loading(null)
viewModelScope.launch {
chargerLoadingTask?.cancel()
chargerLoadingTask = viewModelScope.launch {
try {
val response = api.getChargepointDetail(charger.id)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerDetails.value = Resource.error(response.message(), null)
} else {
chargerDetails.value =
Resource.success(response.body()!!.chargelocations[0] as ChargeLocation)
}
chargerDetails.value = api.getChargepointDetail(referenceData, charger.id)
} catch (e: IOException) {
chargerDetails.value = Resource.error(e.message, null)
e.printStackTrace()
@@ -444,22 +360,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
fun loadChargerById(chargerId: Long) {
chargerDetails.value = Resource.loading(null)
chargerSparse.value = null
viewModelScope.launch {
val response = api.getChargepointDetail(chargerId)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerSparse.value = null
chargerDetails.value = Resource.error(response.message(), null)
} else {
val chargers = response.body()!!.chargelocations
if (chargers.isNotEmpty()) {
val charger = chargers[0] as ChargeLocation
chargerDetails.value =
Resource.success(charger)
chargerSparse.value = charger} else {
chargerDetails.value = Resource.error("not found", null)
referenceData.observeForever(object : Observer<ReferenceData> {
override fun onChanged(refData: ReferenceData) {
referenceData.removeObserver(this)
viewModelScope.launch {
val response = api.getChargepointDetail(refData, chargerId)
chargerDetails.value = response
if (response.status == Status.SUCCESS) {
chargerSparse.value = response.data
} else {
chargerSparse.value = null
}
}
}
}
})
}
}

View File

@@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M9,18.7m-1.4,0a1.4,1.4 0,1 1,2.8 0a1.4,1.4 0,1 1,-2.8 0" />
<path
android:fillColor="#FF000000"
android:pathData="M15,18.7m-1.4,0a1.4,1.4 0,1 1,2.8 0a1.4,1.4 0,1 1,-2.8 0" />
<path
android:pathData="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,0C6.2,17.3 7.4,16.1 8.9,16.1z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000" />
<path
android:fillColor="#FF000000"
android:pathData="M14.7,6.4m-1.3,0a1.3,1.3 0,1 1,2.6 0a1.3,1.3 0,1 1,-2.6 0" />
<path
android:fillColor="#FF000000"
android:pathData="M15.3,10.5m-0.8,0a0.8,0.8 0,1 1,1.6 0a0.8,0.8 0,1 1,-1.6 0" />
<path
android:fillColor="#FF000000"
android:pathData="M8.7,10.5m-0.8,0a0.8,0.8 0,1 1,1.6 0a0.8,0.8 0,1 1,-1.6 0" />
<path
android:fillColor="#FF000000"
android:pathData="M9.3,6.4m-1.3,0a1.3,1.3 0,1 1,2.6 0a1.3,1.3 0,1 1,-2.6 0" />
<path
android:fillColor="#FF000000"
android:pathData="M12,13.1m-1.3,0a1.3,1.3 0,1 1,2.6 0a1.3,1.3 0,1 1,-2.6 0" />
<path
android:pathData="M12,9.1m-6.3,0a6.3,6.3 0,1 1,12.6 0a6.3,6.3 0,1 1,-12.6 0"
android:strokeWidth="1.7"
android:fillColor="#00000000"
android:strokeColor="#000000" />
<path
android:fillColor="#FF000000"
android:pathData="M11,15.4h2v1.3h-2z" />
<path
android:pathData="M10.9,1.3L13.1,1.3"
android:strokeWidth="0.5"
android:fillColor="#00000000"
android:strokeColor="#000000" />
<path
android:fillColor="#FF000000"
android:pathData="M13.1,0.9l0,1.5l1.4,0.7l-0.7,-2.1z" />
<path
android:fillColor="#FF000000"
android:pathData="M10.9,0.9l0,1.5l-1.4,0.7l0.7,-2.1z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M11.88,9.14c1.28,0.06 1.61,1.15 1.63,1.66h1.79c-0.08,-1.98 -1.49,-3.19 -3.45,-3.19C9.64,7.61 8,9 8,12.14c0,1.94 0.93,4.24 3.84,4.24c2.22,0 3.41,-1.65 3.44,-2.95h-1.79c-0.03,0.59 -0.45,1.38 -1.63,1.44C10.55,14.83 10,13.81 10,12.14C10,9.25 11.28,9.16 11.88,9.14zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8S16.41,20 12,20z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<item android:drawable="?selectableItemBackground" />
</layer-list>

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">
<ScrollView
android:id="@+id/scroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/welcomeTitle"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select" />
</ScrollView>
<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:text="@string/pref_data_source"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/scroll" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="28dp"
android:breakStrategy="balanced"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/scroll" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginBottom="24dp"
android:text="@string/lets_go"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/scroll" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,175 @@
<?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">
<ImageView
android:id="@+id/icon1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/iconLabel1"
app:layout_constraintEnd_toEndOf="@+id/iconLabel1"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="@+id/iconLabel1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.65"
app:layout_constraintVertical_chainStyle="packed"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_low"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginTop="4dp"
android:text="&lt; 11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toTopOf="@id/icon4"
app:layout_constraintEnd_toStartOf="@+id/iconLabel2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/icon1" />
<ImageView
android:id="@+id/icon2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel2"
app:layout_constraintStart_toStartOf="@+id/iconLabel2"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_11kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:text="≥ 11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/icon2" />
<ImageView
android:id="@+id/icon3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel3"
app:layout_constraintStart_toStartOf="@+id/iconLabel3"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_20kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:text="≥ 20 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel4"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/icon3" />
<ImageView
android:id="@+id/icon4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:layout_constraintBottom_toTopOf="@id/iconLabel4"
app:layout_constraintEnd_toEndOf="@+id/iconLabel4"
app:layout_constraintStart_toStartOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/iconLabel1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_43kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥ 43 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/iconLabel5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/icon4" />
<ImageView
android:id="@+id/icon5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel5"
app:layout_constraintStart_toStartOf="@+id/iconLabel5"
app:layout_constraintTop_toBottomOf="@+id/iconLabel1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_100kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:text="≥ 100 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="@id/iconLabel3"
app:layout_constraintStart_toEndOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/icon5" />
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="16dp"
android:text="@string/welcome_2_title"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/iconLabel3"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="42dp"
android:breakStrategy="balanced"
android:text="@string/welcome_2"
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_toEndOf="@+id/iconLabel3" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginBottom="24dp"
android:text="@string/got_it"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/iconLabel3" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,61 @@
<?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">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="256dp"
android:layout_height="256dp"
android:layout_marginStart="32dp"
android:layout_marginBottom="48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:lottie_autoPlay="true"
app:lottie_rawRes="@raw/logo_anim"
app:lottie_speed="0.75" />
<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:text="@string/welcome_to_evmap"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/animation_view" />
<TextView
android:id="@+id/welcomeText1"
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:text="@string/welcome_1"
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_toEndOf="@+id/animation_view" />
<Button
android:id="@+id/btnGetStarted"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginBottom="24dp"
android:text="@string/get_started"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/animation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -10,9 +10,7 @@
android:name="net.vonforst.evmap.navigation.NavHostFragment"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:fitsSystemWindows="true"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
android:fitsSystemWindows="true" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioButton
android:id="@+id/rbGoingElectric"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/data_source_goingelectric"
android:textColor="#098ac7"
android:buttonTint="#098ac7"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textView27"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-8dp"
android:layout_marginBottom="8dp"
android:layout_marginStart="32dp"
android:text="@string/data_source_goingelectric_desc" />
<RadioButton
android:id="@+id/rbOpenChargeMap"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/data_source_openchargemap"
android:textColor="#587e25"
android:buttonTint="#587e25"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textView28"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-8dp"
android:layout_marginStart="32dp"
android:text="@string/data_source_openchargemap_desc" />
</RadioGroup>

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.api.goingelectric.ChargeLocation" />
<import type="net.vonforst.evmap.model.ChargeLocation" />
<import type="net.vonforst.evmap.api.goingelectric.Chargepoint" />
<import type="net.vonforst.evmap.model.Chargepoint" />
<import type="net.vonforst.evmap.api.goingelectric.ChargeCard" />
<import type="net.vonforst.evmap.model.ChargeCard" />
<import type="net.vonforst.evmap.api.availability.ChargeLocationStatus" />
@@ -23,6 +23,8 @@
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="net.vonforst.evmap.api.ChargepointApiKt" />
<variable
name="charger"
type="Resource&lt;ChargeLocation&gt;" />
@@ -51,6 +53,10 @@
name="expanded"
type="Boolean" />
<variable
name="apiName"
type="String" />
</data>
<androidx.cardview.widget.CardView
@@ -140,7 +146,7 @@
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{charger.data.formatChargepoints()}"
android:text="@{charger.data.formatChargepoints(ChargepointApiKt.stringProvider(context))}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/txtDistance"
app:layout_constraintStart_toStartOf="@+id/guideline"
@@ -268,14 +274,13 @@
app:layout_constraintGuide_begin="16dp" />
<Button
android:id="@+id/goingelectricButton"
android:id="@+id/sourceButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@string/go_to_goingelectric"
app:layout_constraintBottom_toBottomOf="parent"
android:text="@{@string/source(apiName)}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView4" />
@@ -322,7 +327,7 @@
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:contentDescription="@string/verified"
android:tooltipText="@string/verified_desc"
android:tooltipText="@{@string/verified_desc(apiName)}"
app:goneUnless="@{ charger.data.verified }"
app:layout_constraintBottom_toBottomOf="@+id/txtName"
app:layout_constraintStart_toEndOf="@+id/imgFaultReport"
@@ -347,6 +352,23 @@
app:srcCompat="@drawable/ic_map_marker_fault"
tools:targetApi="o" />
<TextView
android:id="@+id/txtLicense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:textStyle="italic"
android:text="@{charger.data.license}"
android:breakStrategy="balanced"
app:goneUnless="@{charger.data.license != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/sourceButton"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="The data is provided under the National Oman Open Data LicensE (NOODLE), Version 3.14, and may be used for any purpose whatsoever." />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,71 @@
<?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="wrap_content">
<TextView
android:id="@+id/dialogTitle"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="16dp"
android:text="@string/pref_data_source"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/dataSourceDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dialogTitle" />
<Button
android:id="@+id/btnOK"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/ok"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rg_data_source" />
<Button
android:id="@+id/btnCancel"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:text="@string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnOK"
app:layout_constraintTop_toBottomOf="@+id/rg_data_source" />
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dataSourceDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,199 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="16dp">
<include
android:id="@+id/include"
layout="@layout/app_logo"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/welcome_to_evmap"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/include" />
<TextView
android:id="@+id/welcomeText1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/welcome_1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
<ImageView
android:id="@+id/icon1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel1"
app:layout_constraintStart_toStartOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_low"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="&lt;11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/icon1" />
<ImageView
android:id="@+id/icon2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel2"
app:layout_constraintStart_toStartOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_11kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/icon2" />
<ImageView
android:id="@+id/icon3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel3"
app:layout_constraintStart_toStartOf="@+id/iconLabel3"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_20kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥20 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel4"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/icon3" />
<ImageView
android:id="@+id/icon4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel4"
app:layout_constraintStart_toStartOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_43kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥43 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel5"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel3"
app:layout_constraintTop_toBottomOf="@+id/icon4" />
<ImageView
android:id="@+id/icon5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@+id/iconLabel5"
app:layout_constraintStart_toStartOf="@+id/iconLabel5"
app:layout_constraintTop_toBottomOf="@+id/welcomeText1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_100kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥100 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/icon5" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/welcome_2"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iconLabel1" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<Button
android:id="@+id/btnOk"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:layout_gravity="end" />
</LinearLayout>

View File

@@ -7,13 +7,14 @@
<import type="net.vonforst.evmap.viewmodel.ChargepriceViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="vm"
type="ChargepriceViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:id="@+id/linearLayout5"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -21,7 +22,7 @@
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
@@ -51,137 +52,194 @@
</com.google.android.material.appbar.AppBarLayout>
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_select_connector"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:data="@{vm.charger.chargepointsMerged}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_connector_button"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_range"
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:valueFrom="0.0"
android:valueTo="100.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView2"
app:values="@={vm.batteryRange}" />
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:data="@{vm.chargePricesForChargepoint.data}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/battery_range"
tools:itemCount="3"
tools:listitem="@layout/item_chargeprice" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_tariffs_found"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.SUCCESS &amp;&amp; vm.chargePricesForChargepoint.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_select_connector"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/vehicle_selection" />
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_compatible_connectors"
app:goneUnless="@{vm.noCompatibleConnectors}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:data="@{vm.charger.chargepointsMerged}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_connector_button"
tools:orientation="horizontal" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_select_car_first"
app:goneUnless="@{vm.vehicle == null}"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<ProgressBar
android:id="@+id/progressBar5"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="@+id/charge_prices_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
android:text="@{@string/chargeprice_duration(BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)))}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toEndOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="(25 min)" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings"
app:goneUnless="@{vm.vehicle == null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tvVehicleHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_vehicle"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?colorPrimary"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vehicle_selection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvVehicleHeader"
app:data="@{vm.vehicles.data}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
android:orientation="horizontal"
tools:listitem="@layout/item_chargeprice_vehicle_chip" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_range"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:valueFrom="0.0"
android:valueTo="100.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView2"
app:values="@={vm.batteryRange}" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:nestedScrollingEnabled="false"
app:data="@{vm.chargePricesForChargepoint.data}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/battery_range"
tools:itemCount="3"
tools:listitem="@layout/item_chargeprice" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_tariffs_found"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.SUCCESS &amp;&amp; vm.chargePricesForChargepoint.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_compatible_connectors"
app:goneUnless="@{vm.noCompatibleConnectors}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_select_car_first"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list"
app:layout_constraintVertical_chainStyle="packed" />
<ProgressBar
android:id="@+id/progressBar5"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.LOADING || vm.vehicles.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="@+id/charge_prices_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</layout>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="net.vonforst.evmap.api.goingelectric.ChargerPhoto" />
<import type="java.util.List" />
<variable
name="photos"
type="List&lt;ChargerPhoto&gt;" />
</data>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/gallery"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
app:data="@{photos}" />
</layout>

View File

@@ -144,7 +144,8 @@
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"
app:distance="@{vm.chargerDistance}"
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &amp;&amp; vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}" />
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED &amp;&amp; vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}"
app:apiName="@{vm.apiName}" />
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:id="@+id/rl_create_account"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:overScrollMode="never" />
<com.rd.PageIndicatorView
android:id="@+id/pageIndicatorView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:layout_gravity="center_horizontal"
app:piv_animationType="worm"
app:piv_dynamicCount="true"
app:piv_interactiveAnimation="true"
app:piv_selectedColor="@color/colorPrimary"
app:piv_unselectedColor="@color/colorPrimaryTransparent"
app:piv_viewPager="@id/viewPager"
app:piv_padding="8dp"
app:piv_radius="6dp" />
</LinearLayout>

View File

@@ -0,0 +1,63 @@
<?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">
<include
android:id="@+id/rg_data_source"
layout="@layout/data_source_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="56dp"
app:layout_constraintBottom_toTopOf="@+id/btnGetStarted"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="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/pref_data_source"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:gravity="center"
android:text="@string/data_sources_description"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
app:layout_constraintBottom_toTopOf="@+id/rg_data_source"
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"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/lets_go"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,170 @@
<?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">
<ImageView
android:id="@+id/icon1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/iconLabel1"
app:layout_constraintEnd_toEndOf="@+id/iconLabel1"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="@+id/iconLabel1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_low"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="28dp"
android:text="&lt; 11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/iconLabel2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/icon1" />
<ImageView
android:id="@+id/icon2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel2"
app:layout_constraintStart_toStartOf="@+id/iconLabel2"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_11kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥ 11 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel1"
app:layout_constraintTop_toBottomOf="@+id/icon2" />
<ImageView
android:id="@+id/icon3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel3"
app:layout_constraintStart_toStartOf="@+id/iconLabel3"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_20kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥ 20 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel4"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel2"
app:layout_constraintTop_toBottomOf="@+id/icon3" />
<ImageView
android:id="@+id/icon4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel4"
app:layout_constraintStart_toStartOf="@+id/iconLabel4"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_43kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥ 43 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/iconLabel5"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel3"
app:layout_constraintTop_toBottomOf="@+id/icon4" />
<ImageView
android:id="@+id/icon5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/iconLabel5"
app:layout_constraintStart_toStartOf="@+id/iconLabel5"
app:layout_constraintTop_toTopOf="@id/icon1"
app:srcCompat="@drawable/ic_map_marker_charging"
app:tint="@color/charger_100kw"
app:tintMode="multiply" />
<TextView
android:id="@+id/iconLabel5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="≥ 100 kW"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/iconLabel4"
app:layout_constraintTop_toBottomOf="@+id/icon5" />
<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_2_title"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="56dp"
android:gravity="center"
android:text="@string/welcome_2"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
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/got_it"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,65 @@
<?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">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="256dp"
android:layout_height="256dp"
android:layout_marginBottom="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:lottie_autoPlay="true"
app:lottie_rawRes="@raw/logo_anim"
app:lottie_speed="0.75" />
<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_to_evmap"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/welcomeText1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/welcomeText1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="56dp"
android:gravity="center"
android:text="@string/welcome_1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:textColorSecondary"
android:breakStrategy="balanced"
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/get_started"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.ortiz.touchview.TouchImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:fitsSystemWindows="true"
tools:src="@tools:sample/backgrounds/scenic" />

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.api.chargeprice.ChargepriceCar" />
<variable
name="item"
type="ChargepriceCar" />
<variable
name="selectedItem"
type="ChargepriceCar" />
</data>
<com.google.android.material.chip.Chip
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:text="@{item.brand + ' ' + item.name}"
android:checked="@{item == selectedItem}"
tools:text="Tesla Model 2" />
</layout>

View File

@@ -5,7 +5,7 @@
<data>
<import type="net.vonforst.evmap.api.goingelectric.Chargepoint" />
<import type="net.vonforst.evmap.model.Chargepoint" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />

View File

@@ -11,7 +11,7 @@
<variable
name="hours"
type="net.vonforst.evmap.api.goingelectric.OpeningHoursDays" />
type="net.vonforst.evmap.model.OpeningHoursDays" />
<variable
name="dayOfWeek"

View File

@@ -6,29 +6,57 @@
<data>
<import type="net.vonforst.evmap.api.UtilsKt" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="net.vonforst.evmap.api.ChargepointApiKt" />
<variable
name="item"
type="net.vonforst.evmap.viewmodel.FavoritesViewModel.FavoritesListItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackground">
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
<FrameLayout
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/delete_red"> <!--Add your background color here-->
<ImageView
android:id="@+id/delete_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical|end"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
app:tint="@android:color/white"
app:srcCompat="@drawable/ic_delete" />
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/foreground"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{item.charger.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
android:padding="16dp"
android:background="@drawable/selectable_opaque_background">
<TextView
android:id="@+id/textView15"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.charger.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Parkhaus" />
<TextView
android:id="@+id/textView2"
@@ -51,46 +79,64 @@
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{item.charger.formatChargepoints()}"
android:text="@{item.charger.formatChargepoints(ChargepointApiKt.stringProvider(context))}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/textView2"
tools:text="2x Typ 2 22 kW" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/distance_format(item.distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9999,9 km" />
<TextView
android:id="@+id/textView16"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@{@string/distance_format(item.distance)}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.distance != null}"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
app:layout_constraintTop_toTopOf="parent"
tools:text="9999,9 km" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"
app:goneUnless="@{item.available.status == Status.SUCCESS}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:backgroundTint="@color/available"
tools:text="80/99" />
<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:background="@drawable/rounded_rect"
android:padding="2dp"
android:text="@{String.format(&quot;%s/%d&quot;, BindingAdaptersKt.availabilityText(item.available.data), item.total)}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="@android:color/white"
app:backgroundTintAvailability="@{item.available.data}"
app:goneUnless="@{item.available.status == Status.SUCCESS}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
tools:backgroundTint="@color/available"
tools:text="80/99" />
<ProgressBar
android:id="@+id/progressBar4"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
app:goneUnless="@{item.available.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar4"
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{item.available.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnDelete" />
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
app:tint="?colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete"
android:contentDescription="@string/delete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout>

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